diff --git a/README.md b/README.md index 3e877496..1d3081e5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@

- GoClaw + GoClaw

-

GoClaw

- -

Enterprise AI Agent Platform

+

Multi-Tenant AI Agent Platform

Multi-agent AI gateway built in Go. 20+ LLM providers. 7 channels. Multi-tenant PostgreSQL.
diff --git a/_statics/goclaw-icon.svg b/_statics/goclaw-icon.svg new file mode 100644 index 00000000..797941f5 --- /dev/null +++ b/_statics/goclaw-icon.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_statics/goclaw-logo.svg b/_statics/goclaw-logo.svg new file mode 100644 index 00000000..a12bce9b --- /dev/null +++ b/_statics/goclaw-logo.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/gateway.go b/cmd/gateway.go index 78a5b766..754d9592 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "github.com/google/uuid" @@ -45,6 +46,21 @@ func runGateway() { if verbose { logLevel = slog.LevelDebug } + // Env override (docker/K8s friendly, default: info): GOCLAW_LOG_LEVEL=debug|info|warn|error + if lvl := os.Getenv("GOCLAW_LOG_LEVEL"); lvl != "" { + switch strings.ToLower(lvl) { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + fmt.Fprintf(os.Stderr, "warning: unknown GOCLAW_LOG_LEVEL=%q, using info\n", lvl) + } + } textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, }) @@ -64,7 +80,7 @@ func runGateway() { msgBus := bus.New() // Create provider registry - providerRegistry := providers.NewRegistry() + providerRegistry := providers.NewRegistry(store.TenantIDFromContext) registerProviders(providerRegistry, cfg) // Resolve workspace (must be absolute for system prompt + file tool path resolution) @@ -145,12 +161,9 @@ func runGateway() { } // Notify clients that leader is processing team results // (bridges UI gap between last task.completed and announce run.started). - msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamLeaderProcessing, - Payload: map[string]any{ - "agentId": meta.ParentAgent, - "tasks": len(items), - }, + bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{ + "agentId": meta.ParentAgent, + "tasks": len(items), }) msgBus.PublishInbound(bus.InboundMessage{ @@ -159,6 +172,7 @@ func runGateway() { ChatID: meta.OriginChatID, Content: content, UserID: meta.OriginUserID, + TenantID: meta.OriginTenantID, Metadata: batchMeta, Media: batchMedia, }) @@ -170,7 +184,7 @@ func runGateway() { slog.Info("subagent system enabled", "tools", []string{"spawn"}) } - skillsLoader, skillSearchTool, globalSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus) + skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus) _ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity // DateTime tool (precise time for cron scheduling, memory timestamps, etc.) @@ -282,19 +296,10 @@ func runGateway() { server.SetAgentStore(pgStores.Agents) } - // Dynamic custom tools: load global tools from DB before resolver - var dynamicLoader *tools.DynamicToolLoader - if pgStores.CustomTools != nil { - dynamicLoader = tools.NewDynamicToolLoader(pgStores.CustomTools, workspace) - if err := dynamicLoader.LoadGlobal(context.Background(), toolsReg); err != nil { - slog.Warn("failed to load global custom tools", "error", err) - } - } - var mcpPool *mcpbridge.Pool var mediaStore *media.Store var postTurn tools.PostTurnProcessor - contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader, redisClient) + contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, redisClient) if mcpPool != nil { defer mcpPool.Stop() } @@ -303,7 +308,7 @@ func runGateway() { if mcpMgr != nil { mcpToolLister = mcpMgr } - agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) + agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) if providersH != nil { providersH.SetAPIBaseFallback(cfg.Providers.APIBaseForType) } @@ -323,10 +328,13 @@ func runGateway() { } server.SetWakeHandler(wakeH) if mcpH != nil { + if mcpPool != nil { + mcpH.SetPoolEvictor(mcpPool) + } server.SetMCPHandler(mcpH) } - if customToolsH != nil { - server.SetCustomToolsHandler(customToolsH) + if mcpUserCredsH != nil { + server.SetMCPUserCredentialsHandler(mcpUserCredsH) } if channelInstancesH != nil { server.SetChannelInstancesHandler(channelInstancesH) @@ -334,14 +342,11 @@ func runGateway() { if providersH != nil { server.SetProvidersHandler(providersH) } - if delegationsH != nil { - server.SetDelegationsHandler(delegationsH) - } if teamEventsH != nil { server.SetTeamEventsHandler(teamEventsH) } if pgStores != nil && pgStores.Teams != nil { - server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, dataDir)) + server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, workspace)) } if builtinToolsH != nil { server.SetBuiltinToolsHandler(builtinToolsH) @@ -399,7 +404,7 @@ func runGateway() { // Workspace file serving endpoint — serves files by absolute path, auth-token protected. // Supports media from any agent workspace (each agent has its own workspace from DB). - server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace)) + server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace, dataDir)) // Storage file management — browse/delete files under the resolved workspace directory. // Uses GOCLAW_WORKSPACE (or default ~/.goclaw/workspace) so it works correctly @@ -472,7 +477,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) - if err := instanceLoader.LoadAll(context.Background()); err != nil { + if err := instanceLoader.LoadAll(store.WithCrossTenant(context.Background())); err != nil { slog.Error("failed to load channel instances from DB", "error", err) } } @@ -507,7 +512,8 @@ func runGateway() { }) go func() { for payload := range auditCh { - if err := pgStores.Activity.Log(context.Background(), &store.ActivityLog{ + auditCtx := store.WithTenantID(context.Background(), payload.TenantID) + if err := pgStores.Activity.Log(auditCtx, &store.ActivityLog{ ActorType: payload.ActorType, ActorID: payload.ActorID, Action: payload.Action, @@ -612,7 +618,7 @@ func runGateway() { if err != nil { return } - team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID) + team, err := notifyTeamStore.GetTeam(store.WithCrossTenant(context.Background()), teamUUID) if err != nil || team == nil { return } @@ -654,7 +660,7 @@ func runGateway() { // Resolve lead agent key (needed for leader mode routing + completed-by-leader skip). var leadAgentKey string if notifyAgentStore != nil { - if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil { + if la, err := notifyAgentStore.GetByID(store.WithCrossTenant(context.Background()), team.LeadAgentID); err == nil { leadAgentKey = la.AgentKey } } @@ -793,10 +799,11 @@ func runGateway() { // Uses calibrated token estimation (actual prompt tokens from last LLM call) // and the agent's real context window (cached on session by the Loop). sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) { - history := pgStores.Sessions.GetHistory(sessionKey) - lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(sessionKey) + bctx := context.Background() + history := pgStores.Sessions.GetHistory(bctx, sessionKey) + lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(bctx, sessionKey) tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC) - cw := pgStores.Sessions.GetContextWindow(sessionKey) + cw := pgStores.Sessions.GetContextWindow(bctx, sessionKey) if cw <= 0 { cw = config.DefaultContextWindow } @@ -870,6 +877,23 @@ func runGateway() { methods.NewAPIKeysMethods(pgStores.APIKeys).Register(server.Router()) } + // Tenant management RPC + HTTP + if pgStores.Tenants != nil { + methods.NewTenantsMethods(pgStores.Tenants, msgBus, workspace).Register(server.Router()) + server.SetTenantsHandler(httpapi.NewTenantsHandler(pgStores.Tenants, cfg.Gateway.Token, msgBus, workspace)) + server.Router().SetTenantStore(pgStores.Tenants) + // Permission cache for tenant membership checks (tenant role, agent access, etc.) + permCache := cache.NewPermissionCache() + msgBus.Subscribe("permission-cache", func(e bus.Event) { + if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok { + permCache.HandleInvalidation(p) + } + }) + server.Router().SetPermissionCache(permCache) + httpapi.InitTenantStore(pgStores.Tenants, msgBus) + httpapi.InitOwnerIDs(cfg.Gateway.OwnerIDs) + } + // Reload quota config on config changes via pub/sub. if quotaChecker != nil { msgBus.Subscribe("quota-config-reload", func(evt bus.Event) { diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 05654f72..37dce7d7 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "log/slog" "strings" @@ -86,7 +87,7 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem // Fallback: resolve from provider registry (DB-stored providers like "openai-embedding"). // Any OpenAI-compatible provider in the registry can serve embeddings. if providerReg != nil { - if regProv, err := providerReg.Get(name); err == nil { + if regProv, err := providerReg.Get(context.Background(), name); err == nil { if op, ok := regProv.(*providers.OpenAIProvider); ok { embBase := op.APIBase() if apiBase != "" { @@ -102,15 +103,15 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem } func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager) *tools.SubagentManager { - names := providerReg.List() + names := providerReg.List(context.Background()) if len(names) == 0 { return nil } agentCfg := cfg.ResolveAgent("default") - provider, err := providerReg.Get(agentCfg.Provider) + provider, err := providerReg.Get(context.Background(), agentCfg.Provider) if err != nil { - provider, _ = providerReg.Get(names[0]) + provider, _ = providerReg.Get(context.Background(), names[0]) } if provider == nil { return nil diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index fa98b727..71f6f209 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -44,33 +44,26 @@ func builtinToolSeedData() []store.BuiltinToolDef { Requires: []string{"knowledge_graph"}, }, - // media — seed uses chain format: {"providers":[...]} - {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`), + // media — user must configure provider chain via UI before use + {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"vision_provider"}, }, - {Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"document_provider"}, }, - {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: false, Requires: []string{"image_gen_provider"}, }, - {Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"audio_provider"}, }, - {Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"video_provider"}, }, - {Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"veo-3.0-generate-preview","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: false, Requires: []string{"video_gen_provider"}, }, - {Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"minimax","model":"music-2.5+","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: false, Requires: []string{"audio_gen_provider"}, }, {Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to natural-sounding speech audio", Category: "media", Enabled: true, diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 2c0edffd..fc055513 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -143,7 +143,7 @@ func wireChannelEventSubscribers( if !ok || payload.Kind != bus.CacheKindChannelInstances { return } - go instanceLoader.Reload(context.Background()) + go instanceLoader.Reload(store.WithCrossTenant(context.Background())) }) } @@ -197,7 +197,7 @@ func wireChannelEventSubscribers( if err != nil { return } - all, err := ciStore.ListAll(context.Background()) + all, err := ciStore.ListAll(store.WithCrossTenant(context.Background())) if err != nil { slog.Warn("cascade disable: failed to list channel instances", "error", err) return @@ -205,7 +205,7 @@ func wireChannelEventSubscribers( disabled := 0 for _, inst := range all { if inst.AgentID == agentID && inst.Enabled { - if err := ciStore.Update(context.Background(), inst.ID, map[string]any{"enabled": false}); err != nil { + if err := ciStore.Update(store.WithCrossTenant(context.Background()), inst.ID, map[string]any{"enabled": false}); err != nil { slog.Warn("cascade disable: failed to disable channel instance", "name", inst.Name, "error", err) } else { disabled++ diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 53804167..0de0526a 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -120,6 +120,7 @@ func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore if agentStore == nil { return } + // Caller (processNormalMessage) already injected tenant_id into ctx. // agentKey may be a slug ("default") or a UUID string (from WS clients). var ag *store.AgentData var err error diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 9b7a2506..310e668a 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -39,6 +39,13 @@ func handleSubagentAnnounce( return false } + // Inject tenant scope — same as processNormalMessage. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + origChannel := msg.Metadata["origin_channel"] origPeerKind := msg.Metadata["origin_peer_kind"] origLocalKey := msg.Metadata["origin_local_key"] @@ -190,6 +197,13 @@ func handleTeammateMessage( return false } + // Inject tenant scope — same as processNormalMessage. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + origChannel := msg.Metadata["origin_channel"] origPeerKind := msg.Metadata["origin_peer_kind"] origLocalKey := msg.Metadata["origin_local_key"] @@ -346,24 +360,21 @@ func handleTeammateMessage( if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil { slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err) } else { - msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskFailed, - Payload: protocol.TeamTaskEventPayload{ - TeamID: teamID.String(), - TaskID: teamTaskID.String(), - TaskNumber: taskNumber, - Subject: taskSubject, - Status: store.TeamTaskStatusFailed, - Reason: outcome.Err.Error(), - Channel: taskChannel, - ChatID: taskChatID, - Timestamp: now, - ActorType: "agent", - ActorID: toAgent, - }, + bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: teamTaskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, + Status: store.TeamTaskStatusFailed, + Reason: outcome.Err.Error(), + Channel: taskChannel, + ChatID: taskChatID, + Timestamp: now, + ActorType: "agent", + ActorID: toAgent, }) } - } else { + } else if outcome.Result != nil { result := outcome.Result.Content if len(outcome.Result.Deliverables) > 0 { result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n") @@ -374,21 +385,18 @@ func handleTeammateMessage( if err := teamStore.CompleteTask(ctx, teamTaskID, teamID, result); err != nil { slog.Warn("auto-complete: CompleteTask error", "task_id", teamTaskID, "error", err) } else { - msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskCompleted, - Payload: protocol.TeamTaskEventPayload{ - TeamID: teamID.String(), - TaskID: teamTaskID.String(), - TaskNumber: taskNumber, - Subject: taskSubject, - Status: store.TeamTaskStatusCompleted, - OwnerAgentKey: toAgent, - Channel: taskChannel, + bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: teamTaskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, + Status: store.TeamTaskStatusCompleted, + OwnerAgentKey: toAgent, + Channel: taskChannel, ChatID: taskChatID, Timestamp: now, ActorType: "agent", ActorID: toAgent, - }, }) } } @@ -413,6 +421,9 @@ func handleTeammateMessage( errMsg = errMsg[:500] + "..." } announceContent = fmt.Sprintf("[FAILED] %s", errMsg) + } else if outcome.Result == nil { + slog.Warn("teammate message: nil result without error", "from", senderID) + return } else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) { slog.Info("teammate message: suppressed silent/empty reply", "from", senderID) return @@ -560,8 +571,9 @@ func handleResetCommand( sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID) } } - sessStore.Reset(sessionKey) - sessStore.Save(sessionKey) + ctx := store.WithTenantID(context.Background(), msg.TenantID) + sessStore.Reset(ctx, sessionKey) + sessStore.Save(ctx, sessionKey) providers.ResetCLISession("", sessionKey) slog.Info("inbound: /reset command", "session", sessionKey) diff --git a/cmd/gateway_consumer_helpers.go b/cmd/gateway_consumer_helpers.go index 904cc674..420650a9 100644 --- a/cmd/gateway_consumer_helpers.go +++ b/cmd/gateway_consumer_helpers.go @@ -116,11 +116,6 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string { return "" } - tokenQuery := "" - if cfg.Gateway.Token != "" { - tokenQuery = "?token=" + cfg.Gateway.Token - } - var parts []string for _, mr := range media { cleanPath := filepath.Clean(mr.Path) @@ -129,7 +124,9 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string { if urlPath == "" { continue } - fileURL := "/v1/files/" + urlPath + tokenQuery + // Store clean path only — no auth tokens in persisted session messages. + // Frontend adds auth (Bearer header or ?ft= signed token) at render time. + fileURL := "/v1/files/" + urlPath if strings.HasPrefix(mr.ContentType, "image/") { parts = append(parts, fmt.Sprintf("![image](%s)", fileURL)) } else { diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 9035caa0..4a380628 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -38,13 +38,21 @@ func processNormalMessage( postTurn tools.PostTurnProcessor, msgBus *bus.MessageBus, ) { + // Inject tenant from channel instance into context so all store operations + // (agent lookup, session creation, etc.) are tenant-scoped. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + // Determine target agent via bindings or explicit AgentID agentID := msg.AgentID if agentID == "" { agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind) } - agentLoop, err := agents.Get(agentID) + agentLoop, err := agents.Get(ctx, agentID) if err != nil { slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel) return @@ -96,7 +104,7 @@ func processNormalMessage( // Persist friendly names from channel metadata into session + user profile. sessionMeta := extractSessionMetadata(msg, peerKind) if len(sessionMeta) > 0 { - sessStore.SetSessionMetadata(sessionKey, sessionMeta) + sessStore.SetSessionMetadata(ctx, sessionKey, sessionMeta) if agentStore != nil { if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil { _ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta) @@ -298,6 +306,11 @@ func processNormalMessage( } } + // Inject tenant context from channel instance so all store queries are tenant-scoped. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } + // Inject post-turn dispatch tracker so team task creates are deferred. ptd := tools.NewPendingTeamDispatch() schedCtx := tools.WithPendingTeamDispatch(ctx, ptd) diff --git a/cmd/gateway_consumer_process.go b/cmd/gateway_consumer_process.go index 9f75bc17..08be6a06 100644 --- a/cmd/gateway_consumer_process.go +++ b/cmd/gateway_consumer_process.go @@ -30,7 +30,7 @@ func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.Ru } } - loop, err := agents.Get(agentID) + loop, err := agents.Get(ctx, agentID) if err != nil { return nil, fmt.Errorf("agent %s not found: %w", agentID, err) } diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go index 2075f1f6..1abb565b 100644 --- a/cmd/gateway_cron.go +++ b/cmd/gateway_cron.go @@ -60,8 +60,11 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg ) } + // Build context with tenant scope so agent loop events are scoped correctly. + cronCtx := store.WithTenantID(context.Background(), job.TenantID) + // Schedule through cron lane — scheduler handles agent resolution and concurrency - outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{ + outCh := sched.Schedule(cronCtx, scheduler.LaneCron, agent.RunRequest{ SessionKey: sessionKey, Message: job.Payload.Message, Channel: channel, diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 66f9ce24..483df227 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -9,16 +9,14 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages). -func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler) { +// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages). +func wireHTTP(stores *store.Stores, token, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.MCPUserCredentialsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler var mcpH *httpapi.MCPHandler - var customToolsH *httpapi.CustomToolsHandler var channelInstancesH *httpapi.ChannelInstancesHandler var providersH *httpapi.ProvidersHandler - var delegationsH *httpapi.DelegationsHandler var builtinToolsH *httpapi.BuiltinToolsHandler var pendingMessagesH *httpapi.PendingMessagesHandler var secureCLIH *httpapi.SecureCLIHandler @@ -35,7 +33,7 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. if pgSkills, ok := stores.Skills.(*pg.PGSkillStore); ok { dirs := pgSkills.Dirs() if len(dirs) > 0 { - skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], token, msgBus) + skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, token, msgBus) } } } @@ -47,13 +45,13 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. if stores != nil && stores.MCP != nil { mcpH = httpapi.NewMCPHandler(stores.MCP, token, msgBus, mcpToolLister) } - - if stores != nil && stores.CustomTools != nil { - customToolsH = httpapi.NewCustomToolsHandler(stores.CustomTools, token, msgBus, toolsReg) + var mcpUserCredsH *httpapi.MCPUserCredentialsHandler + if stores != nil && stores.MCP != nil { + mcpUserCredsH = httpapi.NewMCPUserCredentialsHandler(stores.MCP, token) } if stores != nil && stores.ChannelInstances != nil { - channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, token, msgBus) + channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, token, msgBus) } if stores != nil && stores.Providers != nil { @@ -67,12 +65,11 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. var teamEventsH *httpapi.TeamEventsHandler if stores != nil && stores.Teams != nil { - delegationsH = httpapi.NewDelegationsHandler(stores.Teams, token) teamEventsH = httpapi.NewTeamEventsHandler(stores.Teams, token) } if stores != nil && stores.BuiltinTools != nil { - builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, token, msgBus) + builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, stores.BuiltinToolTenantCfgs, token, msgBus) } if stores != nil && stores.PendingMessages != nil { @@ -83,5 +80,5 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, token, msgBus) } - return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH + return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 84508a14..5c6db2f7 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -8,9 +8,12 @@ import ( "github.com/google/uuid" + "strings" + "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" @@ -45,7 +48,6 @@ func wireExtras( injectionAction string, appCfg *config.Config, sandboxMgr sandbox.Manager, - dynamicLoader *tools.DynamicToolLoader, redisClient any, // nil when built without -tags redis or when Redis is unconfigured ) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) { // 1. Build cache instances (in-memory or Redis depending on build tags) @@ -115,7 +117,7 @@ func wireExtras( // 5. Shared MCP connection pool (eliminates duplicate connections across agents) var mcpPool *mcpbridge.Pool if stores.MCP != nil { - mcpPool = mcpbridge.NewPool() + mcpPool = mcpbridge.NewPool(mcpbridge.DefaultPoolConfig()) } // 6. Set up agent resolver: lazy-creates Loops from DB @@ -145,7 +147,6 @@ func wireExtras( SandboxEnabled: sandboxEnabled, SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, - DynamicLoader: dynamicLoader, AgentLinkStore: stores.AgentLinks, TeamStore: stores.Teams, DataDir: workspace, @@ -158,10 +159,22 @@ func wireExtras( ModelPricing: appCfg.Telemetry.ModelPricing, TracingStore: stores.Tracing, MemoryStore: stores.Memory, + TenantStore: stores.Tenants, + BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs, + SkillTenantCfgs: stores.SkillTenantCfgs, + Workspace: workspace, OnEvent: func(event agent.AgentEvent) { + // Sign /v1/files/ and /v1/media/ URLs in content before delivery. + // Sessions store clean paths; signing happens only at delivery time. + if m, ok := event.Payload.(map[string]string); ok { + if c, has := m["content"]; has && strings.Contains(c, "/v1/") { + m["content"] = httpapi.SignFileURLs(c, httpapi.FileSigningKey()) + } + } msgBus.Broadcast(bus.Event{ - Name: protocol.EventAgent, - Payload: event, + Name: protocol.EventAgent, + Payload: event, + TenantID: event.TenantID, }) }, }) @@ -381,22 +394,6 @@ func wireExtras( }) } - // Custom tools cache: reload global tools on create/update/delete - if dynamicLoader != nil { - msgBus.Subscribe(bus.TopicCacheCustomTools, func(event bus.Event) { - if event.Name != protocol.EventCacheInvalidate { - return - } - payload, ok := event.Payload.(bus.CacheInvalidatePayload) - if !ok || payload.Kind != bus.CacheKindCustomTools { - return - } - dynamicLoader.ReloadGlobal(context.Background(), toolsReg) - // Invalidate all agent caches so they re-resolve with updated tools - agentRouter.InvalidateAll() - }) - } - // Builtin tools cache: re-apply disables on settings/enabled changes if stores.BuiltinTools != nil { msgBus.Subscribe(bus.TopicCacheBuiltinTools, func(event bus.Event) { @@ -530,7 +527,7 @@ func buildKGExtractFunc(kgStore store.KnowledgeGraphStore, bts store.BuiltinTool return } - p, err := providerReg.Get(settings.ExtractionProvider) + p, err := providerReg.Get(ctx, settings.ExtractionProvider) if err != nil { slog.Warn("kg extract: provider not found", "provider", settings.ExtractionProvider, "error", err) return diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 9749a114..d4544961 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -19,14 +19,14 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore chatMethods := methods.NewChatMethods(agents, sessStore, server.RateLimiter(), msgBus) chatMethods.Register(router) methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router) - methods.NewSessionsMethods(sessStore, msgBus).Register(router) + methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router) methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router) // Phase 2: Skills (uses SkillStore interface — PG or File) methods.NewSkillsMethods(skillStore).Register(router) // Phase 2: Cron (store created externally, shared with gateway) - methods.NewCronMethods(cronStore, msgBus).Register(router) + methods.NewCronMethods(cronStore, msgBus, cfg).Register(router) // Phase 2: Heartbeat heartbeatMethods := methods.NewHeartbeatMethods(heartbeatStore, msgBus) @@ -52,11 +52,6 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore // Phase 3: Live log tailing methods.NewLogsMethods(logTee).Register(router) - // Phase 4: Delegation history - if teamStore != nil { - methods.NewDelegationsMethods(teamStore).Register(router) - } - slog.Info("registered all RPC methods", "phase1", []string{"chat", "agents", "sessions", "config"}, "phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "exec_approval", "send"}, diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index b505c68a..6b4ed746 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -233,7 +233,7 @@ func jsonToStringMap(data json.RawMessage) map[string]string { // mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config. // cfg provides fallback api_base values from config/env when DB providers have none set. func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) { - ctx := context.Background() + ctx := store.WithCrossTenant(context.Background()) dbProviders, err := provStore.ListProviders(ctx) if err != nil { slog.Warn("failed to load providers from DB", "error", err) @@ -265,7 +265,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore) cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData)) } - registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) + registry.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...)) slog.Info("registered provider from DB", "name", p.Name) continue } @@ -280,7 +280,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if host == "" { host = "http://localhost:11434" } - registry.Register(providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3")) slog.Info("registered provider from DB", "name", p.Name) continue } @@ -297,37 +297,37 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi } switch p.ProviderType { case store.ProviderChatGPTOAuth: - ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name) - registry.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) + ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID) + registry.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) case store.ProviderAnthropicNative: - registry.Register(providers.NewAnthropicProvider(p.APIKey, + registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) case store.ProviderDashScope: - registry.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) + registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) case store.ProviderBailian: base := p.APIBase if base == "" { base = "https://coding-intl.dashscope.aliyuncs.com/v1" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) case store.ProviderZai: base := p.APIBase if base == "" { base = "https://api.z.ai/api/paas/v4" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) case store.ProviderZaiCoding: base := p.APIBase if base == "" { base = "https://api.z.ai/api/coding/paas/v4" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) case store.ProviderOllamaCloud: base := p.APIBase if base == "" { base = "https://ollama.com/v1" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3")) case store.ProviderSuno: // Suno is a media-only provider (music gen). Register as OpenAI-compat // so credentialProvider interface works for API key/base extraction. @@ -337,14 +337,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi } prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, "") prov.WithProviderType(p.ProviderType) - registry.Register(prov) + registry.RegisterForTenant(p.TenantID, prov) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") prov.WithProviderType(p.ProviderType) if p.ProviderType == store.ProviderMiniMax { prov.WithChatPath("/text/chatcompletion_v2") } - registry.Register(prov) + registry.RegisterForTenant(p.TenantID, prov) } slog.Info("registered provider from DB", "name", p.Name) } @@ -416,7 +416,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { if workDir == "" { workDir = defaultACPWorkDir() } - registry.Register(providers.NewACPProvider( + registry.RegisterForTenant(p.TenantID, providers.NewACPProvider( binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), providers.WithACPModel(p.Name), )) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index e2543f47..f32d290e 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -201,6 +201,15 @@ func setupToolRegistry( if et, ok := execTool.(*tools.ExecTool); ok { et.DenyPaths(dataDir, ".goclaw/") et.AllowPathExemptions(".goclaw/skills-store/") + // Harden: block access to internal workspace files via shell commands. + // Prevents `cat ../config.json`, `cat memory.db` etc. from user workspaces. + et.DenyPaths( + filepath.Join(workspace, "memory.db"), + filepath.Join(workspace, "memory.db-wal"), + filepath.Join(workspace, "memory.db-shm"), + filepath.Join(workspace, "config.json"), + filepath.Join(workspace, "delegate"), + ) if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" { et.DenyPaths(cfgPath) } @@ -214,11 +223,17 @@ func setupToolRegistry( // deny paths add defense-in-depth. internalDenyPaths := []string{ "config.json", "memory.db", "memory.db-wal", "memory.db-shm", - "memory/", ".media/", "delegate/", + "memory/", ".media/", ".uploads/", "delegate/", + } + // read_file: allow .media/ access (uploaded documents accessed via AllowPaths + // for backward compat; new uploads go to per-user .uploads/ within workspace). + readFileDenyPaths := []string{ + "config.json", "memory.db", "memory.db-wal", "memory.db-shm", + "memory/", "delegate/", } if rf, ok := toolsReg.Get("read_file"); ok { if t, ok := rf.(*tools.ReadFileTool); ok { - t.DenyPaths(internalDenyPaths...) + t.DenyPaths(readFileDenyPaths...) } } if wf, ok := toolsReg.Get("write_file"); ok { @@ -338,7 +353,7 @@ func setupMemoryEmbeddings( if pgStores.Memory != nil { memCfg := cfg.Agents.Defaults.Memory if pgStores.Agents != nil { - if defaultAgent, agErr := pgStores.Agents.GetByKey(context.Background(), "default"); agErr == nil { + if defaultAgent, agErr := pgStores.Agents.GetByKey(store.WithCrossTenant(context.Background()), "default"); agErr == nil { if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil { memCfg = agentMemCfg slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey) @@ -452,7 +467,9 @@ func setupSkillsSystem( toolsReg *tools.Registry, providerRegistry *providers.Registry, msgBus *bus.MessageBus, -) (*skills.Loader, *tools.SkillSearchTool, string) { +) (*skills.Loader, *tools.SkillSearchTool, string, string) { + var bundledSkillsDir string // resolved later; returned for HTTP handler fallback + // Skills loader + search tool // Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/. globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR") @@ -469,7 +486,7 @@ func setupSkillsSystem( skillSearchTool := tools.NewSkillSearchTool(skillsLoader) toolsReg.Register(skillSearchTool) toolsReg.Register(tools.NewUseSkillTool()) - slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills())) + slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(store.WithCrossTenant(context.Background())))) // Wire skills-store directory into filesystem loader so agents // can discover uploaded skills in their system prompt and BM25 search index. @@ -480,7 +497,7 @@ func setupSkillsSystem( slog.Info("skills-store directory wired into loader", "dir", storeDirs[0]) // Seed system/bundled skills into DB - bundledSkillsDir := os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR") + bundledSkillsDir = os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR") if bundledSkillsDir == "" { // Check common locations: Docker default, then local dev for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} { @@ -518,7 +535,7 @@ func setupSkillsSystem( if len(storeDirs) > 0 { toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader)) slog.Info("publish_skill tool registered") - toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], skillsLoader)) + toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], dataDir, skillsLoader)) slog.Info("skill_manage tool registered") } } @@ -549,6 +566,6 @@ func setupSkillsSystem( } } - return skillsLoader, skillSearchTool, globalSkillsDir + return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir } diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 542d4691..49dc1459 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "os" @@ -30,7 +31,7 @@ func skillsListCmd() *cobra.Command { Short: "List all available skills", Run: func(cmd *cobra.Command, args []string) { loader := loadSkillsLoader() - allSkills := loader.ListSkills() + allSkills := loader.ListSkills(context.Background()) if jsonOutput { data, _ := json.MarshalIndent(allSkills, "", " ") @@ -66,7 +67,7 @@ func skillsShowCmd() *cobra.Command { Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { loader := loadSkillsLoader() - info, ok := loader.GetSkill(args[0]) + info, ok := loader.GetSkill(context.Background(), args[0]) if !ok { fmt.Fprintf(os.Stderr, "Skill not found: %s\n", args[0]) os.Exit(1) @@ -77,7 +78,7 @@ func skillsShowCmd() *cobra.Command { fmt.Printf("Location: %s\n", info.Path) fmt.Println() - content, ok := loader.LoadSkill(args[0]) + content, ok := loader.LoadSkill(context.Background(), args[0]) if ok { fmt.Println("--- Content ---") fmt.Println(content) diff --git a/docs/18-http-api.md b/docs/18-http-api.md index ee4d95bc..4cae6e79 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -32,6 +32,7 @@ API keys are hashed with SHA-256 before lookup — the raw key is never stored. | `Authorization` | Bearer token for authentication | | `X-GoClaw-User-Id` | External user ID for multi-tenant context | | `X-GoClaw-Agent-Id` | Agent identifier for scoped operations | +| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) | | `Accept-Language` | Locale (`en`, `vi`, `zh`) for i18n error messages | | `Content-Type` | `application/json` for request bodies | diff --git a/docs/23-multi-tenant-architecture.md b/docs/23-multi-tenant-architecture.md new file mode 100644 index 00000000..ac801a84 --- /dev/null +++ b/docs/23-multi-tenant-architecture.md @@ -0,0 +1,387 @@ +# Multi-Tenant Integration Guide + +GoClaw is an **AI agent gateway** — it handles agents, chat, sessions, tools, MCP servers, and memory. It supports two deployment modes: + +1. **Personal / Single-tenant** — Use GoClaw directly as your AI backend. Built-in dashboard included. +2. **SaaS / Multi-tenant** — Integrate GoClaw behind your application. API keys bridge the two systems. + +--- + +## Deployment Modes + +### Mode 1: Personal Use (Single-Tenant) + +Use GoClaw as a standalone AI backend with its built-in web dashboard. No separate frontend or backend needed. + +```mermaid +graph LR + U[You] -->|browser| GC[GoClaw Dashboard
+ Gateway] + GC --> AG[Agents / Chat / Tools] + AG --> DB[(PostgreSQL)] + AG -->|LLM calls| LLM[Anthropic / OpenAI / Gemini / ...] +``` + +**How it works:** +- Log in with the gateway token via the built-in web dashboard +- Create agents, configure LLM providers, chat — all from the dashboard +- Connect chat channels (Telegram, Discord, etc.) for messaging +- All data lives under the default "master" tenant — no tenant config needed + +**Setup:** + +```bash +# 1. Build and onboard +go build -o goclaw . && ./goclaw onboard + +# 2. Start the gateway +source .env.local && ./goclaw + +# 3. Open dashboard at http://localhost:3777 +# Log in with your gateway token + user ID "system" +``` + +**When to use:** Personal AI assistant, small team, self-hosted AI tools, development/testing. + +**Scaling up:** When you need multiple isolated environments (clients, departments, projects), create additional tenants. Multi-tenant features activate automatically — no migration needed. + +--- + +### Mode 2: SaaS Integration (Multi-Tenant) + +Integrate GoClaw as the AI engine behind your SaaS application. Your app handles auth, billing, and UI. GoClaw handles AI. Each tenant is fully isolated — agents, sessions, memory, teams, providers, and files. + +```mermaid +graph TB + subgraph "Tenant A" + FEa[Frontend A] + BEa[Backend A] + TGa[Telegram Bot A] + end + + subgraph "Tenant B" + FEb[Frontend B] + BEb[Backend B] + DCb[Discord Bot B] + end + + subgraph "GoClaw Gateway" + subgraph "Entry Points" + HTTP[HTTP API] + WS[WebSocket] + CH[Channel Manager] + end + + TI{Tenant Isolation
Layer} + + subgraph "Tenant-Scoped Engine" + AG[Agent Loop] + TOOLS[Tools / Skills / MCP] + MEM[Memory / KG] + end + + DB[(PostgreSQL
WHERE tenant_id = $N)] + end + + FEa -->|authenticated| BEa + BEa -->|API Key A + user_id| HTTP + TGa -->|webhook| CH + + FEb -->|authenticated| BEb + BEb -->|API Key B + user_id| HTTP + DCb -->|webhook| CH + + HTTP --> TI + WS --> TI + CH --> TI + + TI -->|ctx with tenant_id| AG + AG --> TOOLS + AG --> MEM + AG --> DB + TOOLS --> DB + MEM --> DB +``` + +**How it works:** +- Each tenant's **backend connects via a tenant-bound API key** — GoClaw auto-scopes all data +- **Chat channels** (Telegram, Discord, etc.) connect directly — tenant resolved from channel instance config +- The **Tenant Isolation Layer** resolves tenant_id from credentials and injects it into Go context +- Every SQL query enforces `WHERE tenant_id = $N` — fail-closed, no cross-tenant leakage + +**When to use:** SaaS products with AI features, multi-client platforms, white-label AI solutions. + +### Connection Types Summary + +All connections go through the **Tenant Isolation Layer** before reaching the agent engine: + +| Connection | Auth Method | Tenant Resolution | Isolation | +|------------|-------------|-------------------|-----------| +| **HTTP API** | `Bearer` token (API key or gateway token) | Auto from API key's `tenant_id` | Per-request | +| **WebSocket** | Token on `connect` (API key or gateway token) | Auto from API key's `tenant_id` | Per-session | +| **Chat Channels** | None (direct webhook/WS) | Baked into channel instance DB config | Per-instance | +| **Dashboard** | Gateway token or browser pairing | User's tenant membership | Per-session | + +**Tenant Isolation Layer** — resolves credentials → injects `tenant_id` into Go `context.Context` → all downstream SQL queries enforce `WHERE tenant_id = $N`. Fail-closed: missing tenant = error, never unfiltered data. + +--- + +## Tenant Setup (Multi-Tenant Only) + +```mermaid +sequenceDiagram + participant Admin as System Admin + participant GC as GoClaw API + + Admin->>GC: tenants.create {name: "Acme Corp", slug: "acme"} + GC-->>Admin: {id: "tenant-uuid", slug: "acme"} + + Admin->>GC: tenants.users.add {tenant_id, user_id: "user-123", role: "admin"} + + Admin->>GC: api_keys.create {tenant_id, scopes: ["operator.read", "operator.write"]} + GC-->>Admin: {key: "goclaw_sk_abc123..."} + + Note over Admin: Store API key in your backend's config/secrets +``` + +Each tenant gets isolated: **agents, sessions, teams, memory, LLM providers, MCP servers, skills**. A tenant-bound API key automatically scopes every request — no extra headers needed. + +--- + +## Tenant Resolution + +GoClaw determines the tenant from the credentials used to connect: + +| Credential | Tenant Resolution | Use Case | +|------------|-------------------|----------| +| **Gateway token** + owner user ID | All tenants (cross-tenant) | System administration | +| **Gateway token** + non-owner user ID | User's tenant membership | Dashboard users | +| **API key** (tenant-bound) | Auto from key's `tenant_id` | Normal SaaS integration | +| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug) | Cross-tenant admin tools | +| **Browser pairing** | Paired tenant | Dashboard operators | +| **No credentials** | Master tenant | Dev/single-user mode | + +**Owner IDs:** Configured via `GOCLAW_OWNER_IDS` env var (comma-separated). Only owners get cross-tenant access with the gateway token. Default: `system`. + +**Recommended for SaaS**: Use **tenant-bound API keys**. The tenant is resolved automatically from the key — your backend doesn't need to send any tenant header. + +--- + +## HTTP API + +All HTTP endpoints accept standard headers: + +| Header | Required | Description | +|--------|:---:|-------------| +| `Authorization` | Yes | `Bearer ` | +| `X-GoClaw-User-Id` | Yes | Your app's user ID (max 255 chars). Scopes sessions and per-user data | +| `X-GoClaw-Tenant-Id` | No | Tenant UUID or slug. Only needed for system-level keys | +| `X-GoClaw-Agent-Id` | No | Target agent ID (alternative to `model` field) | +| `Accept-Language` | No | Locale for error messages: `en`, `vi`, `zh` | + +### Chat (OpenAI-Compatible) + +```bash +curl -X POST https://goclaw.example.com/v1/chat/completions \ + -H "Authorization: Bearer goclaw_sk_abc123..." \ + -H "X-GoClaw-User-Id: user-456" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "agent:my-agent", + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +The `model` field uses `agent:` format. The API key is bound to tenant "Acme Corp" — the response only includes data from that tenant. + +### List Resources + +```bash +# List agents +curl https://goclaw.example.com/v1/agents \ + -H "Authorization: Bearer goclaw_sk_abc123..." \ + -H "X-GoClaw-User-Id: user-456" + +# List sessions +curl https://goclaw.example.com/v1/sessions \ + -H "Authorization: Bearer goclaw_sk_abc123..." \ + -H "X-GoClaw-User-Id: user-456" +``` + +### System Admin (Cross-Tenant) + +```bash +# List agents for a specific tenant (requires gateway token + owner user ID) +curl https://goclaw.example.com/v1/agents \ + -H "Authorization: Bearer $GATEWAY_TOKEN" \ + -H "X-GoClaw-Tenant-Id: acme" \ + -H "X-GoClaw-User-Id: system" +``` + +--- + +## WebSocket Integration + +For real-time features (streaming chat, live events), connect via WebSocket: + +```mermaid +sequenceDiagram + participant FE as Your Frontend + participant BE as Your Backend + participant GC as GoClaw Gateway + + FE->>BE: User sends message (authenticated) + BE->>GC: WS connect {token: "goclaw_sk_abc...", user_id: "user-456"} + GC-->>BE: {role: "operator", tenant_id, tenant_name, tenant_slug} + + BE->>GC: chat.send {agent_key: "my-agent", message: "Hello"} + GC-->>BE: event: agent {type: "chunk", content: "Hi..."} + BE-->>FE: Stream response to user + GC-->>BE: event: agent {type: "run.completed"} +``` + +After `connect`, **all methods are auto-scoped** to the API key's tenant. Events are server-side filtered — your backend only receives events belonging to its tenant. + +**Protocol**: Frame types `req` (client→server), `res` (server→client), `event` (async push). Protocol version 3. + +--- + +## Chat Channels + +Chat channels (Telegram, Discord, Zalo, Slack, WhatsApp, Feishu) connect **directly** to GoClaw — no API key needed. Tenant isolation is baked into the channel instance at registration time. + +```mermaid +sequenceDiagram + participant TG as Telegram + participant CH as Channel Manager + participant TI as Tenant Isolation + participant AG as Agent Loop + participant DB as PostgreSQL + + Note over CH: Channel instance loaded from DB
with tenant_id baked in + + TG->>CH: Incoming message (webhook) + CH->>TI: Resolve tenant from instance config + TI->>AG: ctx with tenant_id + user_id + AG->>DB: Query with WHERE tenant_id = $N + AG-->>CH: Agent response + CH-->>TG: Reply to user +``` + +Each channel instance stores its `tenant_id` in the `channel_instances` table. When a message arrives, the Channel Manager looks up the instance config and injects the tenant context — no headers or tokens required. + +| Channel | Protocol | Tenant Source | +|---------|----------|---------------| +| Telegram | Webhook | Instance config | +| Discord | WebSocket | Instance config | +| Zalo | Webhook | Instance config | +| Slack | Events API | Instance config | +| WhatsApp | Webhook | Instance config | +| Feishu/Lark | Webhook | Instance config | + +--- + +## API Key Scopes + +API keys use scopes to control access level: + +| Scope | Role | Permissions | +|-------|------|-------------| +| `operator.admin` | admin | Full access — agents, config, API keys, tenants | +| `operator.read` | viewer | Read-only — list agents, sessions, configs | +| `operator.write` | operator | Read + write — chat, create sessions, manage agents | +| `operator.approvals` | operator | Approve/reject execution requests | +| `operator.provision` | operator | Create tenants + manage tenant users | +| `operator.pairing` | operator | Manage device pairing | + +A key with `["operator.read", "operator.write"]` gets `operator` role. A key with `["operator.admin"]` gets `admin` role. + +--- + +## Per-Tenant Overrides + +Tenants can customize their environment without affecting other tenants: + +| Feature | Scope | How | +|---------|-------|-----| +| **LLM Providers** | Per-tenant provider configs | Each tenant registers own API keys + models | +| **Builtin Tools** | Enable/disable per tenant | `builtin_tool_tenant_configs` table | +| **Skills** | Enable/disable per tenant | `skill_tenant_configs` table | +| **MCP Servers** | Per-tenant + per-user credentials | Server-level shared, user-level overrides | +| **MCP Require User Credentials** | Per-server setting | `settings.require_user_credentials` — forces per-user API keys | + +MCP servers support two credential tiers: +- **Server-level** (shared): configured in the MCP server form, used by all users +- **User-level** (overrides): configured via "My Credentials", per-user API keys merged at runtime (user wins on key collision) + +When `require_user_credentials` is enabled, users without personal credentials cannot use that MCP server. + +--- + +## Security + +| Concern | How GoClaw Handles It | +|---------|-----------------------| +| API key exposure | Keys stay in your backend — never sent to browser | +| Cross-tenant data access | All SQL queries include `WHERE tenant_id = $N` (fail-closed) | +| Event leakage | Server-side 3-mode filter: unscoped admin, scoped admin, regular user | +| Missing tenant context | Fail-closed: returns error, never unfiltered data | +| API key storage | Keys hashed with SHA-256 at rest; only prefix shown in UI | +| Tenant impersonation | Tenant resolved from API key binding, not client headers | +| Privilege escalation | Role derived from key scopes, not client claims | +| Gateway token abuse | Only configured owner IDs get cross-tenant; others are tenant-scoped | +| System config access | Config page restricted to cross-tenant owners only | +| Logout isolation | Tenant scope cleared from localStorage on logout | +| Tenant access revocation | Proactive WS event + `TENANT_ACCESS_REVOKED` error forces immediate UI logout | +| File URL security | HMAC-signed file tokens (`?ft=`) — no gateway token in URLs | + +--- + +## Tenant Data Model + +```mermaid +erDiagram + TENANTS ||--o{ TENANT_USERS : "members" + TENANTS ||--o{ API_KEYS : "keys" + TENANTS ||--o{ AGENTS : "owns" + TENANTS ||--o{ SESSIONS : "owns" + TENANTS ||--o{ TEAMS : "owns" + TENANTS ||--o{ LLM_PROVIDERS : "configures" + TENANTS ||--o{ MCP_SERVERS : "registers" + TENANTS ||--o{ SKILLS : "manages" + + TENANTS { + uuid id PK + string name + string slug UK + string status + jsonb settings + } + + TENANT_USERS { + uuid tenant_id FK + string user_id + string role + } + + API_KEYS { + uuid id PK + uuid tenant_id FK "NULL = system key" + string owner_id + text[] scopes + boolean revoked + } +``` + +40+ tables carry `tenant_id` with NOT NULL constraint. Exception: `api_keys.tenant_id` is nullable — NULL means system-level cross-tenant key. + +**Master tenant** (UUID `0193a5b0-7000-7000-8000-000000000001`): All legacy/default data. Single-tenant deployments use this exclusively. + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GOCLAW_OWNER_IDS` | `system` | Comma-separated user IDs with cross-tenant access | +| `GOCLAW_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | +| `GOCLAW_CONFIG` | `config.json5` | Path to gateway config file | diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f05b0e1d..111e5d7a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -43,6 +43,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if l.agentUUID != uuid.Nil { ctx = store.WithAgentID(ctx, l.agentUUID) } + // Inject tenant into context for tool-level tenant scoping (spawn, MCP, etc.) + if l.tenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, l.tenantID) + } // Inject user ID into context for per-user scoping (memory, context files, etc.) if req.UserID != "" { ctx = store.WithUserID(ctx, req.UserID) @@ -182,7 +186,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if tools.IsSharedWorkspace(team.Settings) { wsChat = "" } - if wsDir, err := tools.WorkspaceDir(l.dataDir, team.ID, wsChat); err == nil { + tenantBase := config.TenantWorkspace(l.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)) + if wsDir, err := tools.WorkspaceDir(tenantBase, team.ID, wsChat); err == nil { ctx = tools.WithToolTeamWorkspace(ctx, wsDir) if team.LeadAgentID == l.agentUUID { ctx = tools.WithToolWorkspace(ctx, wsDir) @@ -196,7 +201,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Persist agent UUID + user ID on the session (for querying/tracing) if l.agentUUID != uuid.Nil || req.UserID != "" { - l.sessions.SetAgentInfo(req.SessionKey, l.agentUUID, req.UserID) + l.sessions.SetAgentInfo(ctx, req.SessionKey, l.agentUUID, req.UserID) } // Security: scan user message for injection patterns. @@ -247,19 +252,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // 0. Cache agent's context window on the session (first run only). // Enables scheduler's adaptive throttle to use the real value instead of hardcoded 200K. - if l.sessions.GetContextWindow(req.SessionKey) <= 0 { - l.sessions.SetContextWindow(req.SessionKey, l.contextWindow) + if l.sessions.GetContextWindow(ctx, req.SessionKey) <= 0 { + l.sessions.SetContextWindow(ctx, req.SessionKey, l.contextWindow) } // 0b. Load adaptive tool timing from session metadata. - toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(req.SessionKey)) + toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(ctx, req.SessionKey)) // Resolve slow_tool notification config from already-loaded team settings (no extra DB query). slowToolEnabled := tools.ParseTeamNotifyConfig(resolvedTeamSettings).SlowTool // 1. Build messages from session history - history := l.sessions.GetHistory(req.SessionKey) - summary := l.sessions.GetSummary(req.SessionKey) + history := l.sessions.GetHistory(ctx, req.SessionKey) + summary := l.sessions.GetSummary(ctx, req.SessionKey) // buildMessages resolves context files once and also detects BOOTSTRAP.md presence // (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection. @@ -279,14 +284,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // 2. Process media: sanitize images, persist to media store. var mediaRefs []providers.MediaRef if len(req.Media) > 0 { - mediaRefs = l.persistMedia(req.SessionKey, req.Media) - // Load current-turn images from persisted refs. + mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx)) + // Load current-turn images from persisted refs (Path is always set for new uploads). var imageFiles []bus.MediaFile for _, ref := range mediaRefs { - if ref.Kind == "image" { - if p, err := l.mediaStore.LoadPath(ref.ID); err == nil { - imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType}) - } + if ref.Kind == "image" && ref.Path != "" { + imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType}) } } if images := loadImages(imageFiles); len(images) > 0 { @@ -384,7 +387,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if len(mediaRefs) > 0 && l.mediaStore != nil { var mediaPaths []string for _, ref := range mediaRefs { - if p, err := l.mediaStore.LoadPath(ref.ID); err == nil { + // Prefer workspace-local path (.uploads/) over canonical .media/ path. + if ref.Path != "" { + mediaPaths = append(mediaPaths, ref.Path) + } else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil { mediaPaths = append(mediaPaths, p) } } @@ -636,6 +642,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) toolDefs = l.tools.ProviderDefs() } + // Per-tenant tool exclusions: remove tools disabled for this agent's tenant. + if len(l.disabledTools) > 0 { + filtered := toolDefs[:0] + for _, td := range toolDefs { + if !l.disabledTools[td.Function.Name] { + filtered = append(filtered, td) + } else { + delete(allowedTools, td.Function.Name) + } + } + toolDefs = filtered + } + // Bootstrap mode: restrict API tool definitions to write_file only (open agents). // Predefined agents keep all tools — BOOTSTRAP.md guides behavior. if hadBootstrap && l.agentType != store.AgentTypePredefined { @@ -874,7 +893,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if sanitized != "" && !IsSilentReply(sanitized) { blockReplies++ lastBlockReply = sanitized - l.emit(AgentEvent{ + emitRun(AgentEvent{ Type: protocol.AgentEventBlockReply, AgentID: l.id, RunID: req.RunID, @@ -1032,6 +1051,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } else if mr := parseMediaResult(result.ForLLM); mr != nil { mediaResults = append(mediaResults, *mr) } + // Auto-attach workspace media to task (covers create_image/audio/video). + if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" { + for _, mf := range result.Media { + tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path) + } + } if result.Deliverable != "" { deliverables = append(deliverables, result.Deliverable) } @@ -1229,6 +1254,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } else if mr := parseMediaResult(r.result.ForLLM); mr != nil { mediaResults = append(mediaResults, *mr) } + // Auto-attach workspace media to task (covers create_image/audio/video). + if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" { + for _, mf := range r.result.Media { + tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path) + } + } if r.result.Deliverable != "" { deliverables = append(deliverables, r.result.Deliverable) } @@ -1403,27 +1434,27 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Flush all buffered messages to session atomically. // This ensures concurrent runs never see each other's in-progress messages. for _, msg := range pendingMsgs { - l.sessions.AddMessage(req.SessionKey, msg) + l.sessions.AddMessage(ctx, req.SessionKey, msg) } // Persist adaptive tool timing to session metadata. if serialized := toolTiming.Serialize(); serialized != "" { - l.sessions.SetSessionMetadata(req.SessionKey, map[string]string{"tool_timing": serialized}) + l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized}) } // Write session metadata (matching TS session entry updates) - l.sessions.UpdateMetadata(req.SessionKey, l.model, l.provider.Name(), req.Channel) - l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens)) + l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel) + l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens)) // Calibrate token estimation: store actual prompt tokens + message count. // Next time EstimateTokensWithCalibration() is called, it uses this as a base // instead of the chars/3 heuristic (more accurate for multilingual content). if totalUsage.PromptTokens > 0 { msgCount := len(history) + len(pendingMsgs) - l.sessions.SetLastPromptTokens(req.SessionKey, totalUsage.PromptTokens, msgCount) + l.sessions.SetLastPromptTokens(ctx, req.SessionKey, totalUsage.PromptTokens, msgCount) } - l.sessions.Save(req.SessionKey) + l.sessions.Save(ctx, req.SessionKey) // Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md // as a safety net in case the LLM didn't clear it itself. diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 7e67d883..bbbdd3c2 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -179,7 +179,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s OwnerIDs: l.ownerIDs, Mode: mode, ToolNames: toolNames, - SkillsSummary: l.resolveSkillsSummary(skillFilter), + SkillsSummary: l.resolveSkillsSummary(ctx, skillFilter), HasMemory: l.hasMemory, HasSpawn: l.tools != nil && hasSpawn, HasTeam: hasTeamTools, @@ -231,8 +231,8 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s slog.Info("sanitizeHistory: cleaned session history", "session", sessionKey, "dropped", droppedCount) cleanedHistory, _ := sanitizeHistory(history) - l.sessions.SetHistory(sessionKey, cleanedHistory) - l.sessions.Save(sessionKey) + l.sessions.SetHistory(ctx, sessionKey, cleanedHistory) + l.sessions.Save(ctx, sessionKey) } // Current user message @@ -305,7 +305,7 @@ const ( // Returns (summary XML, useInline) — useInline=true means skills are inlined and // the system prompt should use TS-style "scan " instructions // instead of "use skill_search". -func (l *Loop) resolveSkillsSummary(skillFilter []string) string { +func (l *Loop) resolveSkillsSummary(ctx context.Context, skillFilter []string) string { if l.skillsLoader == nil { return "" } @@ -316,7 +316,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string { allowList = skillFilter } - filtered := l.skillsLoader.FilterSkills(allowList) + filtered := l.skillsLoader.FilterSkills(ctx, allowList) if len(filtered) == 0 { return "" } @@ -330,7 +330,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string { if len(filtered) <= skillInlineMaxCount && estimatedTokens <= skillInlineMaxTokens { // Inline mode: build full XML summary - return l.skillsLoader.BuildSummary(allowList) + return l.skillsLoader.BuildSummary(ctx, allowList) } // Search mode: no XML in prompt, agent uses skill_search tool @@ -471,10 +471,10 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { } func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { - history := l.sessions.GetHistory(sessionKey) + history := l.sessions.GetHistory(ctx, sessionKey) // Use calibrated token estimation when available. - lastPT, lastMC := l.sessions.GetLastPromptTokens(sessionKey) + lastPT, lastMC := l.sessions.GetLastPromptTokens(ctx, sessionKey) tokenEstimate := EstimateTokensWithCalibration(history, lastPT, lastMC) // Resolve compaction thresholds from config with sensible defaults. @@ -505,7 +505,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { // Memory flush runs synchronously INSIDE the guard // (so concurrent runs don't both trigger flush for the same compaction cycle). flushSettings := ResolveMemoryFlushSettings(l.compactionCfg) - if l.shouldRunMemoryFlush(sessionKey, tokenEstimate, flushSettings) { + if l.shouldRunMemoryFlush(ctx, sessionKey, tokenEstimate, flushSettings) { l.runMemoryFlush(ctx, sessionKey, flushSettings) } @@ -521,15 +521,15 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { // Re-check: history may have been truncated by a concurrent summarize // that finished between our threshold check and acquiring the lock. - history := l.sessions.GetHistory(sessionKey) + sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 120*time.Second) + defer cancel() + + history := l.sessions.GetHistory(sctx, sessionKey) if len(history) <= keepLast { return } - sctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - summary := l.sessions.GetSummary(sessionKey) + summary := l.sessions.GetSummary(sctx, sessionKey) toSummarize := history[:len(history)-keepLast] var sb strings.Builder @@ -579,10 +579,10 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { return } - l.sessions.SetSummary(sessionKey, SanitizeAssistantContent(resp.Content)) - l.sessions.TruncateHistory(sessionKey, keepLast) - l.sessions.IncrementCompaction(sessionKey) - l.sessions.Save(sessionKey) + l.sessions.SetSummary(sctx, sessionKey, SanitizeAssistantContent(resp.Content)) + l.sessions.TruncateHistory(sctx, sessionKey, keepLast) + l.sessions.IncrementCompaction(sctx, sessionKey) + l.sessions.Save(sctx, sessionKey) }() } diff --git a/internal/agent/loop_run.go b/internal/agent/loop_run.go index 8b329335..051774f2 100644 --- a/internal/agent/loop_run.go +++ b/internal/agent/loop_run.go @@ -29,6 +29,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { event.UserID = req.UserID event.Channel = req.Channel event.ChatID = req.ChatID + event.TenantID = store.TenantIDFromContext(ctx) l.emit(event) } @@ -160,7 +161,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { traceCtx := ctx traceStatus := store.TraceStatusError if ctx.Err() != nil { - traceCtx = context.Background() + traceCtx = context.WithoutCancel(ctx) traceStatus = store.TraceStatusCancelled } l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "") diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index f7805a5b..caabdbf3 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -65,6 +65,10 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration span.AgentID = &l.agentUUID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } // Verbose mode: include input messages (same stripping as emitLLMSpan). if collector.Verbose() && len(messages) > 0 { @@ -195,6 +199,10 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName, span.AgentID = &l.agentUUID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) return spanID @@ -302,6 +310,10 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st span.AgentID = &l.agentUUID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) } diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 00c509bf..9d8840a6 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -40,6 +40,7 @@ type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID st type Loop struct { id string agentUUID uuid.UUID // set for context propagation + tenantID uuid.UUID // agent's owning tenant agentType string // "open" or "predefined" provider providers.Provider model string @@ -108,6 +109,9 @@ type Loop struct { // Global builtin tool settings (from builtin_tools table) builtinToolSettings tools.BuiltinToolSettings + // Per-tenant disabled tools (tool name → true means excluded from LLM) + disabledTools map[string]bool + // Thinking level for extended thinking support thinkingLevel string @@ -160,6 +164,9 @@ type AgentEvent struct { UserID string `json:"userId,omitempty"` Channel string `json:"channel,omitempty"` ChatID string `json:"chatId,omitempty"` + + // TenantID scopes this event to a specific tenant for filtering (not serialized). + TenantID uuid.UUID `json:"-"` } // LoopConfig configures a new Loop. @@ -209,9 +216,10 @@ type LoopConfig struct { // Shell deny group overrides (nil = all defaults) ShellDenyGroups map[string]bool - // Agent UUID for context propagation to tools + // Agent UUID + tenant for context propagation to tools AgentUUID uuid.UUID - AgentType string // "open" or "predefined" + TenantID uuid.UUID // agent's owning tenant — injected into execution context + AgentType string // "open" or "predefined" // Per-user file seeding + dynamic context loading EnsureUserFiles EnsureUserFilesFunc @@ -229,6 +237,9 @@ type LoopConfig struct { // Global builtin tool settings (from builtin_tools table) BuiltinToolSettings tools.BuiltinToolSettings + // Per-tenant disabled tools (tool name → true means excluded) + DisabledTools map[string]bool + // Thinking level: "off", "low", "medium", "high" (from agent other_config) ThinkingLevel string @@ -298,6 +309,7 @@ func NewLoop(cfg LoopConfig) *Loop { return &Loop{ id: cfg.ID, agentUUID: cfg.AgentUUID, + tenantID: cfg.TenantID, agentType: cfg.AgentType, provider: cfg.Provider, model: cfg.Model, @@ -337,6 +349,7 @@ func NewLoop(cfg LoopConfig) *Loop { injectionAction: action, maxMessageChars: cfg.MaxMessageChars, builtinToolSettings: cfg.BuiltinToolSettings, + disabledTools: cfg.DisabledTools, thinkingLevel: cfg.ThinkingLevel, selfEvolve: cfg.SelfEvolve, skillEvolve: cfg.SkillEvolve, diff --git a/internal/agent/media.go b/internal/agent/media.go index 6223d66e..3895a531 100644 --- a/internal/agent/media.go +++ b/internal/agent/media.go @@ -3,12 +3,16 @@ package agent import ( "encoding/base64" "fmt" + "io" "log/slog" "os" "path/filepath" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/providers" ) @@ -50,19 +54,20 @@ func loadImages(files []bus.MediaFile) []providers.ImageContent { return images } -// persistMedia sanitizes images, saves all media files to persistent storage, -// and returns lightweight MediaRefs. Non-image files are saved as-is. -// If mediaStore is nil, falls back to legacy behavior (no persistence, delete temp files). -func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []providers.MediaRef { - if l.mediaStore == nil { - // Fallback: no persistent storage configured. - // slog.Warn("media: no media store configured, temp files will be deleted", "agent", l.id) - // Keep workspace files — don't delete originals. - // defer func() { - // for _, f := range files { - // _ = os.Remove(f.Path) - // } - // }() +// persistMedia sanitizes images, saves all media files to the per-user workspace +// .uploads/ directory, and returns lightweight MediaRefs with persisted paths. +// All media types (images, documents, audio, video) are stored within the user's +// workspace for filesystem-level tenant isolation. +// workspace is the per-user workspace path from ToolWorkspaceFromCtx(ctx). +func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile, workspace string) []providers.MediaRef { + if workspace == "" { + slog.Warn("media: no workspace, cannot persist media") + return nil + } + + uploadsDir := filepath.Join(workspace, ".uploads") + if err := os.MkdirAll(uploadsDir, 0755); err != nil { + slog.Warn("media: failed to create .uploads dir", "dir", uploadsDir, "error", err) return nil } @@ -70,49 +75,80 @@ func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []provider for _, f := range files { mime := f.MimeType if mime == "" { - mime = mimeFromExt(filepath.Ext(f.Path)) // fallback for legacy callers + mime = mimeFromExt(filepath.Ext(f.Path)) } kind := mediaKindFromMime(mime) // Sanitize images before persistent storage. srcPath := f.Path + var sanitizedTemp string // track temp file for cleanup if kind == "image" { sanitized, err := SanitizeImage(f.Path) if err != nil { slog.Warn("media: sanitize image failed, using original", "path", f.Path, "error", err) } else { srcPath = sanitized + sanitizedTemp = sanitized mime = "image/jpeg" // sanitized output is always JPEG - // Keep workspace files — don't delete originals after sanitization. - // if sanitized != f.Path { - // _ = os.Remove(f.Path) - // } } } - id, _, err := l.mediaStore.SaveFile(sessionKey, srcPath, mime) - if err != nil { + id := uuid.New().String() + ext := media.ExtFromMime(mime) + if ext == "" { + ext = filepath.Ext(srcPath) // fallback to source extension + } + dstPath := filepath.Join(uploadsDir, id+ext) + + if err := copyMediaFile(srcPath, dstPath); err != nil { slog.Warn("media: failed to persist file", "path", f.Path, "error", err) - // Keep workspace files — don't delete on persist failure. - // _ = os.Remove(srcPath) + if sanitizedTemp != "" { + os.Remove(sanitizedTemp) + } continue } + if sanitizedTemp != "" { + os.Remove(sanitizedTemp) // cleanup sanitized temp file + } refs = append(refs, providers.MediaRef{ ID: id, MimeType: mime, Kind: kind, + Path: dstPath, }) - slog.Debug("media: persisted file", "id", id, "kind", kind, "mime", mime, "agent", l.id) + slog.Debug("media: persisted file", "id", id, "kind", kind, "path", dstPath, "agent", l.id) } return refs } +// copyMediaFile copies src to dst using buffered I/O. +// Removes partial dst file on failure. +func copyMediaFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(dst) // cleanup partial file + return err + } + return out.Close() +} + // enrichDocumentPaths updates the last user message to include persisted file paths // in tags. This allows skills (e.g. pdf skill via exec) to access // the file directly, matching how Claude Code skills work with file paths. func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef) { - if l.mediaStore == nil || len(messages) == 0 { + if len(messages) == 0 { return } // Find last user message @@ -132,8 +168,16 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider if ref.Kind != "document" { continue } - p, err := l.mediaStore.LoadPath(ref.ID) - if err != nil { + // Use persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" && l.mediaStore != nil { + var err error + p, err = l.mediaStore.LoadPath(ref.ID) + if err != nil { + continue + } + } + if p == "" { continue } // Replace or with version that includes path. @@ -307,7 +351,7 @@ const maxMediaReloadMessages = 5 // reloadMediaForMessages populates Images on historical messages that have image MediaRefs. // Only reloads the last maxMessages messages with image refs (newest first) to limit context usage. func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int) { - if l.mediaStore == nil || maxMessages <= 0 { + if maxMessages <= 0 { return } @@ -324,9 +368,16 @@ func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int) continue } hasImageRef = true - p, err := l.mediaStore.LoadPath(ref.ID) - if err != nil { - slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err) + p := ref.Path + if p == "" && l.mediaStore != nil { + var err error + p, err = l.mediaStore.LoadPath(ref.ID) + if err != nil { + slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err) + continue + } + } + if p == "" { continue } imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType}) diff --git a/internal/agent/media_sanitize.go b/internal/agent/media_sanitize.go index a06ead50..813f80df 100644 --- a/internal/agent/media_sanitize.go +++ b/internal/agent/media_sanitize.go @@ -7,7 +7,6 @@ import ( "image/jpeg" "image/png" "os" - "path/filepath" "github.com/disintegration/imaging" ) @@ -51,10 +50,17 @@ func SanitizeImage(inputPath string) (string, error) { return "", fmt.Errorf("encode jpeg (q=%d): %w", quality, err) } if buf.Len() <= imageSanitizeMaxBytes { - outPath := filepath.Join(os.TempDir(), fmt.Sprintf("goclaw_sanitized_%d.jpg", os.Getpid())) - if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + f, err := os.CreateTemp("", "goclaw_sanitized_*.jpg") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + outPath := f.Name() + if _, err := f.Write(buf.Bytes()); err != nil { + f.Close() + os.Remove(outPath) return "", fmt.Errorf("write sanitized image: %w", err) } + f.Close() return outPath, nil } } diff --git a/internal/agent/media_tool_routing.go b/internal/agent/media_tool_routing.go index e2231837..61369346 100644 --- a/internal/agent/media_tool_routing.go +++ b/internal/agent/media_tool_routing.go @@ -69,8 +69,11 @@ func (l *Loop) loadHistoricalImagesForTool(ctx context.Context, currentRefs []pr continue } hasImage = true - p, err := l.mediaStore.LoadPath(ref.ID) - if err != nil { + p := ref.Path + if p == "" && l.mediaStore != nil { + p, _ = l.mediaStore.LoadPath(ref.ID) + } + if p == "" { continue } histPaths = append(histPaths, bus.MediaFile{Path: p, MimeType: ref.MimeType}) diff --git a/internal/agent/memoryflush.go b/internal/agent/memoryflush.go index 1abed466..223cda57 100644 --- a/internal/agent/memoryflush.go +++ b/internal/agent/memoryflush.go @@ -67,7 +67,7 @@ func ResolveMemoryFlushSettings(compaction *config.CompactionConfig) *MemoryFlus // shouldRunMemoryFlush checks whether a memory flush should run before compaction. // Flush always runs when compaction triggers (called inside maybeSummarize), // gated only by enabled/memory checks and a dedup guard per compaction cycle. -func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool { +func (l *Loop) shouldRunMemoryFlush(ctx context.Context, sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool { if settings == nil || !settings.Enabled || !l.hasMemory { return false } @@ -77,8 +77,8 @@ func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings } // Deduplication: skip if already flushed in this compaction cycle. - compactionCount := l.sessions.GetCompactionCount(sessionKey) - lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(sessionKey) + compactionCount := l.sessions.GetCompactionCount(ctx, sessionKey) + lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(ctx, sessionKey) if lastFlushAt >= 0 && lastFlushAt == compactionCount { return false } @@ -95,8 +95,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings * defer cancel() // Build messages: system prompt + history summary + flush prompt - history := l.sessions.GetHistory(sessionKey) - summary := l.sessions.GetSummary(sessionKey) + history := l.sessions.GetHistory(ctx, sessionKey) + summary := l.sessions.GetSummary(ctx, sessionKey) var messages []providers.Message @@ -203,8 +203,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings * } // Mark flush as done - l.sessions.SetMemoryFlushDone(sessionKey) - l.sessions.Save(sessionKey) + l.sessions.SetMemoryFlushDone(ctx, sessionKey) + l.sessions.Save(ctx, sessionKey) slog.Info("memory flush: completed", "session", sessionKey) } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 137fde47..a6e0a03f 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -50,9 +50,6 @@ type ResolverDeps struct { SandboxContainerDir string SandboxWorkspaceAccess string - // Dynamic custom tools - DynamicLoader *tools.DynamicToolLoader - // Inter-agent delegation AgentLinkStore store.AgentLinkStore @@ -89,13 +86,22 @@ type ResolverDeps struct { // Memory store for extractive memory fallback MemoryStore store.MemoryStore + + // Tenant store for workspace path resolution + TenantStore store.TenantStore + + // Per-tenant tool/skill config overrides + BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore + SkillTenantCfgs store.SkillTenantConfigStore + + // Global workspace root (GOCLAW_WORKSPACE) + Workspace string } // NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data. // Agents are defined in Postgres, not config.json. func NewManagedResolver(deps ResolverDeps) ResolverFunc { - return func(agentKey string) (Agent, error) { - ctx := context.Background() + return func(ctx context.Context, agentKey string) (Agent, error) { // Support lookup by UUID (e.g. from cron jobs that store agent_id as UUID) var ag *store.AgentData @@ -113,15 +119,15 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { return nil, fmt.Errorf("agent %s is inactive", agentKey) } - // Resolve provider - provider, err := deps.ProviderReg.Get(ag.Provider) + // Resolve provider (tenant-aware: tries tenant-specific first, falls back to master) + provider, err := deps.ProviderReg.GetForTenant(ag.TenantID, ag.Provider) if err != nil { - // Fallback to any available provider - names := deps.ProviderReg.List() + // Fallback to any available provider for this tenant + names := deps.ProviderReg.ListForTenant(ag.TenantID) if len(names) == 0 { return nil, fmt.Errorf("no providers configured for agent %s", agentKey) } - provider, _ = deps.ProviderReg.Get(names[0]) + provider, _ = deps.ProviderReg.GetForTenant(ag.TenantID, names[0]) slog.Warn("agent provider not found, using fallback", "agent", agentKey, "wanted", ag.Provider, "using", names[0]) if tl := ag.ParseThinkingLevel(); tl != "" && tl != "off" { @@ -210,27 +216,33 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { sandboxCfgOverride = &resolved } - // Expand ~ in workspace path and ensure directory exists + // Resolve tenant slug once for workspace + dataDir scoping. + var tenantSlug string + if ag.TenantID != store.MasterTenantID && ag.TenantID != uuid.Nil { + tenantSlug = resolveTenantSlug(deps.TenantStore, ag.TenantID) + } + + // Expand ~ in workspace path and ensure directory exists. + // For non-master tenants, prefix workspace with tenant slug directory. workspace := ag.Workspace if workspace != "" { workspace = config.ExpandHome(workspace) if !filepath.IsAbs(workspace) { workspace, _ = filepath.Abs(workspace) } + } + if tenantSlug != "" { + if deps.Workspace != "" { + workspace = config.TenantWorkspace(deps.Workspace, ag.TenantID, tenantSlug) + } + } + if workspace != "" { if err := os.MkdirAll(workspace, 0755); err != nil { slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err) } } - // Per-agent custom tools (clone registry if agent has custom tools) toolsReg := deps.Tools - if deps.DynamicLoader != nil { - if agentReg, err := deps.DynamicLoader.LoadForAgent(ctx, deps.Tools, ag.ID); err != nil { - slog.Warn("failed to load custom tools", "agent", agentKey, "error", err) - } else if agentReg != nil { - toolsReg = agentReg - } - } // Per-agent MCP servers: connect to granted MCP servers and register their tools. // Uses a per-agent MCP Manager that queries the MCPServerStore for accessible servers. @@ -292,6 +304,18 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } } + // Load per-tenant tool exclusions (disabled tools for this agent's tenant) + var disabledTools map[string]bool + if deps.BuiltinToolTenantCfgs != nil && ag.TenantID != uuid.Nil { + if disabled, err := deps.BuiltinToolTenantCfgs.ListDisabled(ctx, ag.TenantID); err == nil && len(disabled) > 0 { + disabledTools = make(map[string]bool, len(disabled)) + for _, name := range disabled { + disabledTools[name] = true + } + slog.Debug("tenant tool exclusions", "agent", agentKey, "tenant", ag.TenantID, "disabled", len(disabled)) + } + } + // Filter skills by visibility + agent grants. // Only public skills and explicitly granted internal skills appear in the system prompt. var skillAllowList []string @@ -308,10 +332,17 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } } + // Resolve tenant-scoped DataDir for team workspace resolution. + dataDir := deps.DataDir + if tenantSlug != "" { + dataDir = config.TenantDataDir(deps.DataDir, ag.TenantID, tenantSlug) + } + restrictVal := true // always restrict agents to their workspace loop := NewLoop(LoopConfig{ ID: ag.AgentKey, AgentUUID: ag.ID, + TenantID: ag.TenantID, AgentType: ag.AgentType, Provider: provider, Model: ag.Model, @@ -319,7 +350,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { MaxTokens: ag.ParseMaxTokens(), MaxIterations: maxIter, Workspace: workspace, - DataDir: deps.DataDir, + DataDir: dataDir, RestrictToWs: &restrictVal, SubagentsCfg: ag.ParseSubagentsConfig(), MemoryCfg: ag.ParseMemoryConfig(), @@ -346,6 +377,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, BuiltinToolSettings: builtinSettings, + DisabledTools: disabledTools, ThinkingLevel: ag.ParseThinkingLevel(), SelfEvolve: ag.ParseSelfEvolve(), SkillEvolve: ag.AgentType == store.AgentTypePredefined && ag.ParseSkillEvolve(), @@ -385,6 +417,19 @@ func (r *Router) InvalidateAll() { slog.Debug("invalidated all agent caches") } +// resolveTenantSlug looks up the tenant slug for workspace path resolution. +// Returns the tenant ID string as fallback if lookup fails. +func resolveTenantSlug(ts store.TenantStore, tenantID uuid.UUID) string { + if ts == nil { + return tenantID.String() + } + tenant, err := ts.GetTenant(store.WithCrossTenant(context.Background()), tenantID) + if err != nil || tenant == nil { + return tenantID.String() + } + return tenant.Slug +} + func derefInt(p *int) int { if p == nil { return 0 diff --git a/internal/agent/router.go b/internal/agent/router.go index f3e251b7..486624e2 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -5,11 +5,15 @@ import ( "fmt" "sync" "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" ) // ResolverFunc is called when an agent isn't found in the cache. -// Used to lazy-create agents from DB. -type ResolverFunc func(agentKey string) (Agent, error) +// Used to lazy-create agents from DB. Context carries tenant scope. +type ResolverFunc func(ctx context.Context, agentKey string) (Agent, error) const defaultRouterTTL = 10 * time.Minute @@ -64,9 +68,12 @@ func (r *Router) Register(ag Agent) { // Get returns an agent by ID. Lazy-creates from DB via resolver if needed. // Cached entries expire after TTL as a safety net for multi-instance deployments. -func (r *Router) Get(agentID string) (Agent, error) { +// Cache key includes tenant so the same agent_key in different tenants resolves independently. +func (r *Router) Get(ctx context.Context, agentID string) (Agent, error) { + cacheKey := agentCacheKey(ctx, agentID) + r.mu.RLock() - entry, ok := r.agents[agentID] + entry, ok := r.agents[cacheKey] resolver := r.resolver r.mu.RUnlock() @@ -77,23 +84,23 @@ func (r *Router) Get(agentID string) (Agent, error) { // TTL expired → remove stale entry so resolver re-creates if ok { r.mu.Lock() - delete(r.agents, agentID) + delete(r.agents, cacheKey) r.mu.Unlock() } // Try resolver (create from DB) if resolver != nil { - ag, err := resolver(agentID) + ag, err := resolver(ctx, agentID) if err != nil { return nil, err } r.mu.Lock() // Double-check: another goroutine might have created it - if existing, ok := r.agents[agentID]; ok { + if existing, ok := r.agents[cacheKey]; ok { r.mu.Unlock() return existing.agent, nil } - r.agents[agentID] = &agentEntry{agent: ag, cachedAt: time.Now()} + r.agents[cacheKey] = &agentEntry{agent: ag, cachedAt: time.Now()} r.mu.Unlock() return ag, nil } @@ -101,6 +108,15 @@ func (r *Router) Get(agentID string) (Agent, error) { return nil, fmt.Errorf("agent not found: %s", agentID) } +// agentCacheKey builds a tenant-scoped cache key for the agent router. +func agentCacheKey(ctx context.Context, agentID string) string { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return agentID + } + return tid.String() + ":" + agentID +} + // Remove removes an agent from the router. func (r *Router) Remove(agentID string) { r.mu.Lock() diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index 61f32378..f260e766 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -63,6 +63,12 @@ func (s *seedStubStore) GetByKey(_ context.Context, _ string) (*store.AgentData, func (s *seedStubStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *seedStubStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { + return nil, nil +} +func (s *seedStubStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) { + return nil, nil +} func (s *seedStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } func (s *seedStubStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } func (s *seedStubStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil } diff --git a/internal/bus/broadcast_helpers.go b/internal/bus/broadcast_helpers.go new file mode 100644 index 00000000..5ca181d7 --- /dev/null +++ b/internal/bus/broadcast_helpers.go @@ -0,0 +1,10 @@ +package bus + +import "github.com/google/uuid" + +// BroadcastForTenant broadcasts an event with explicit tenant scoping. +// Use this for events that carry tenant-specific data to ensure proper +// isolation in the WS event filter (event_filter.go). +func BroadcastForTenant(pub EventPublisher, name string, tenantID uuid.UUID, payload any) { + pub.Broadcast(Event{Name: name, TenantID: tenantID, Payload: payload}) +} diff --git a/internal/bus/types.go b/internal/bus/types.go index 6cf66205..095df11c 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -3,6 +3,8 @@ package bus import ( "context" "encoding/json" + + "github.com/google/uuid" ) // MediaFile represents an inbound media file with its MIME type. @@ -21,6 +23,7 @@ type InboundMessage struct { Media []MediaFile `json:"media,omitempty"` SessionKey string `json:"session_key"` // deprecated: gateway builds canonical key PeerKind string `json:"peer_kind,omitempty"` // "direct" or "group" (used for session key) + TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope from channel instance AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing) UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap) HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config) @@ -46,8 +49,9 @@ type MediaAttachment struct { // Event represents a server-side event to broadcast to WebSocket clients. type Event struct { - Name string `json:"name"` // event name (e.g. "agent", "chat", "health") - Payload any `json:"payload,omitempty"` + Name string `json:"name"` // event name (e.g. "agent", "chat", "health") + Payload any `json:"payload,omitempty"` + TenantID uuid.UUID `json:"-"` // tenant scope for event filtering (not serialized to clients) } // Cache invalidation kind constants. @@ -56,7 +60,6 @@ const ( CacheKindBootstrap = "bootstrap" CacheKindSkills = "skills" CacheKindCron = "cron" - CacheKindCustomTools = "custom_tools" CacheKindChannelInstances = "channel_instances" CacheKindBuiltinTools = "builtin_tools" CacheKindTeam = "team" @@ -67,6 +70,10 @@ const ( CacheKindAPIKeys = "api_keys" CacheKindHeartbeat = "heartbeat" CacheKindConfigPerms = "config_perms" + CacheKindTenantUsers = "tenant_users" + CacheKindAgentAccess = "agent_access" + CacheKindTeamAccess = "team_access" + CacheKindTenants = "tenants" ) // Topic constants for msgBus.Subscribe() / Broadcast(). @@ -75,7 +82,6 @@ const ( TopicCacheAgent = "cache:agent" TopicCacheSkills = "cache:skills" TopicCacheCron = "cache:cron" - TopicCacheCustomTools = "cache:custom_tools" TopicCacheBuiltinTools = "cache:builtin_tools" TopicCacheTeam = "cache:team" TopicCacheUserWorkspace = "cache:user_workspace" @@ -122,6 +128,7 @@ type AuditEventPayload struct { EntityID string `json:"entity_id"` IPAddress string `json:"ip_address,omitempty"` Details json.RawMessage `json:"details,omitempty"` + TenantID uuid.UUID `json:"tenant_id,omitempty"` // for async subscriber tenant scoping } // CacheInvalidatePayload signals cache layers to evict stale entries. diff --git a/internal/cache/permission_cache.go b/internal/cache/permission_cache.go new file mode 100644 index 00000000..744387a8 --- /dev/null +++ b/internal/cache/permission_cache.go @@ -0,0 +1,121 @@ +package cache + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// agentAccessEntry caches agent access check results. +type agentAccessEntry struct { + Allowed bool + Role string +} + +// PermissionCache provides short-TTL caching for hot permission lookups. +// Uses InMemoryCache[V] caches with pubsub invalidation. +type PermissionCache struct { + tenantResolve *InMemoryCache[uuid.UUID] + tenantRole *InMemoryCache[string] + agentAccess *InMemoryCache[agentAccessEntry] + teamAccess *InMemoryCache[bool] +} + +// NewPermissionCache creates a new permission cache. +func NewPermissionCache() *PermissionCache { + return &PermissionCache{ + tenantResolve: NewInMemoryCache[uuid.UUID](), + tenantRole: NewInMemoryCache[string](), + agentAccess: NewInMemoryCache[agentAccessEntry](), + teamAccess: NewInMemoryCache[bool](), + } +} + +const ( + tenantResolveTTL = 60 * time.Second + tenantRoleTTL = 30 * time.Second + agentAccessTTL = 30 * time.Second + teamAccessTTL = 30 * time.Second +) + +// --- Tenant Resolution --- + +func (pc *PermissionCache) GetTenantResolve(ctx context.Context, userID string) (uuid.UUID, bool) { + return pc.tenantResolve.Get(ctx, userID) +} + +func (pc *PermissionCache) SetTenantResolve(ctx context.Context, userID string, tenantID uuid.UUID) { + pc.tenantResolve.Set(ctx, userID, tenantID, tenantResolveTTL) +} + +// --- Tenant Role --- + +func (pc *PermissionCache) GetTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, bool) { + return pc.tenantRole.Get(ctx, tenantID.String()+":"+userID) +} + +func (pc *PermissionCache) SetTenantRole(ctx context.Context, tenantID uuid.UUID, userID, role string) { + pc.tenantRole.Set(ctx, tenantID.String()+":"+userID, role, tenantRoleTTL) +} + +// --- Agent Access --- + +func (pc *PermissionCache) GetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, bool) { + entry, ok := pc.agentAccess.Get(ctx, agentID.String()+":"+userID) + if !ok { + return false, "", false + } + return entry.Allowed, entry.Role, true +} + +func (pc *PermissionCache) SetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string, allowed bool, role string) { + pc.agentAccess.Set(ctx, agentID.String()+":"+userID, agentAccessEntry{Allowed: allowed, Role: role}, agentAccessTTL) +} + +// --- Team Access --- + +func (pc *PermissionCache) GetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, bool) { + return pc.teamAccess.Get(ctx, teamID.String()+":"+userID) +} + +func (pc *PermissionCache) SetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string, allowed bool) { + pc.teamAccess.Set(ctx, teamID.String()+":"+userID, allowed, teamAccessTTL) +} + +// --- Invalidation --- + +// HandleInvalidation processes a cache invalidation event from the bus. +func (pc *PermissionCache) HandleInvalidation(p bus.CacheInvalidatePayload) { + slog.Debug("perm_cache.invalidated", "kind", string(p.Kind), "key", p.Key) + ctx := context.Background() + switch p.Kind { + case bus.CacheKindTenantUsers: + // Key is userID — invalidate resolve + all tenant roles for this user. + // Can't efficiently delete all tenantRole entries for a user by prefix, + // so clear all tenant roles (short TTL makes this acceptable). + if p.Key != "" { + pc.tenantResolve.Delete(ctx, p.Key) + pc.tenantRole.Clear(ctx) + } else { + pc.tenantResolve.Clear(ctx) + pc.tenantRole.Clear(ctx) + } + case bus.CacheKindAgentAccess: + // Key is agentID — delete all access entries for this agent. + if p.Key != "" { + pc.agentAccess.DeleteByPrefix(ctx, p.Key+":") + } else { + pc.agentAccess.Clear(ctx) + } + case bus.CacheKindTeamAccess: + // Key is teamID — delete all access entries for this team. + if p.Key != "" { + pc.teamAccess.DeleteByPrefix(ctx, p.Key+":") + } else { + pc.teamAccess.Clear(ctx) + } + } +} diff --git a/internal/channels/channel.go b/internal/channels/channel.go index 5d4d26e8..c4bf4570 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -14,6 +14,8 @@ import ( "net/http" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -156,6 +158,7 @@ type BaseChannel struct { running bool allowList []string agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute) + tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback) contactCollector *store.ContactCollector // optional: auto-collect contacts from channel messages } @@ -191,6 +194,12 @@ func (c *BaseChannel) AgentID() string { return c.agentID } // SetAgentID sets the explicit agent ID for routing (used by InstanceLoader for DB instances). func (c *BaseChannel) SetAgentID(id string) { c.agentID = id } +// TenantID returns the tenant UUID for this channel (zero = master tenant fallback). +func (c *BaseChannel) TenantID() uuid.UUID { return c.tenantID } + +// SetTenantID sets the tenant scope (used by InstanceLoader for DB instances). +func (c *BaseChannel) SetTenantID(id uuid.UUID) { c.tenantID = id } + // SetContactCollector sets the contact collector for auto-collecting contacts from messages. func (c *BaseChannel) SetContactCollector(cc *store.ContactCollector) { c.contactCollector = cc } @@ -325,6 +334,7 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st PeerKind: peerKind, UserID: userID, Metadata: metadata, + TenantID: c.tenantID, AgentID: c.agentID, } diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index 80f96a62..89f90dbd 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -70,7 +70,7 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair config: cfg, requireMention: requireMention, pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore, base.TenantID()), historyLimit: historyLimit, agentStore: agentStore, configPermStore: configPermStore, diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index db665c5d..77c74d72 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -13,10 +13,13 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // handleMessage processes incoming Discord messages. func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) // Ignore bot's own messages if m.Author == nil || m.Author.ID == c.botUserID { return @@ -40,11 +43,11 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } if isDM { - if !c.checkDMPolicy(senderID, channelID) { + if !c.checkDMPolicy(ctx, senderID, channelID) { return } } else { - if !c.checkGroupPolicy(senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID) { slog.Debug("discord group message rejected by policy", "user_id", senderID, "username", senderName, @@ -192,7 +195,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group") } slog.Debug("discord group message recorded (no mention)", @@ -284,7 +287,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind) } // Publish directly to bus (to preserve MediaFile MIME types) @@ -307,7 +310,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. -func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -327,7 +330,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { } groupSenderID := fmt.Sprintf("group:%s", channelID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -338,7 +341,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { return true } } - c.sendPairingReply(groupSenderID, channelID) + c.sendPairingReply(ctx, groupSenderID, channelID) return false default: // "open" return true @@ -346,7 +349,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { } // checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. -func (c *Channel) checkDMPolicy(senderID, channelID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -367,7 +370,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -382,13 +385,13 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { return true } - c.sendPairingReply(senderID, channelID) + c.sendPairingReply(ctx, senderID, channelID) return false } } // sendPairingReply sends a pairing code to the user via DM. -func (c *Channel) sendPairingReply(senderID, channelID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) { if c.pairingService == nil { return } @@ -400,7 +403,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil) if err != nil { slog.Debug("discord pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/channels/events.go b/internal/channels/events.go index af552f7e..b261423f 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -6,7 +6,10 @@ import ( "log/slog" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -28,6 +31,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { } ctx := context.Background() + if ta, ok := ch.(interface{ TenantID() uuid.UUID }); ok { + ctx = store.WithTenantID(ctx, ta.TenantID()) + } // Forward to StreamingChannel if sc, ok := ch.(StreamingChannel); ok { diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index 9d01195d..e03829b0 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -62,7 +62,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // 4. Group policy if mc.ChatType == "group" { - if !c.checkGroupPolicy(mc.SenderID, mc.ChatID) { + if !c.checkGroupPolicy(ctx, mc.SenderID, mc.ChatID) { slog.Debug("feishu group message rejected by policy", "sender_id", mc.SenderID, "chat_id", mc.ChatID) return } @@ -99,7 +99,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // 6. DM policy (pairing flow) if mc.ChatType == "p2p" { - if !c.checkDMPolicy(mc.SenderID, mc.ChatID) { + if !c.checkDMPolicy(ctx, mc.SenderID, mc.ChatID) { return } } diff --git a/internal/channels/feishu/bot_policy.go b/internal/channels/feishu/bot_policy.go index f91724da..470c3a19 100644 --- a/internal/channels/feishu/bot_policy.go +++ b/internal/channels/feishu/bot_policy.go @@ -46,7 +46,7 @@ func (c *Channel) fetchSenderName(ctx context.Context, openID string) string { // --- Policy checks --- -func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { groupPolicy := c.cfg.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -85,7 +85,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { } groupSenderID := fmt.Sprintf("group:%s", chatID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -96,14 +96,14 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { return true } } - c.sendPairingReply(groupSenderID, chatID) + c.sendPairingReply(ctx, groupSenderID, chatID) return false default: // "open" return true } } -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.cfg.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -124,7 +124,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -139,12 +139,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { return true } - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { return } @@ -156,7 +156,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index da87c40e..7db0c75d 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -86,7 +86,7 @@ func New(cfg config.FeishuConfig, msgBus *bus.MessageBus, pairingSvc store.Pairi client: client, pairingService: pairingSvc, groupAllowList: cfg.GroupAllowFrom, - groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore, base.TenantID()), historyLimit: historyLimit, stopCh: make(chan struct{}), }, nil @@ -255,7 +255,8 @@ func (c *Channel) WebhookHandler() (string, http.Handler) { } handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) { - c.handleMessageEvent(context.Background(), event) + ctx := store.WithTenantID(context.Background(), c.TenantID()) + c.handleMessageEvent(ctx, event) }) return path, http.HandlerFunc(handler) @@ -275,7 +276,8 @@ func (c *Channel) startWebhook(ctx context.Context) error { slog.Info("feishu: starting Webhook server", "port", port, "path", path) handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) { - c.handleMessageEvent(context.Background(), event) + ctx := store.WithTenantID(context.Background(), c.TenantID()) + c.handleMessageEvent(ctx, event) }) mux := http.NewServeMux() diff --git a/internal/channels/history.go b/internal/channels/history.go index a5470e30..fb077ac6 100644 --- a/internal/channels/history.go +++ b/internal/channels/history.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -56,6 +57,9 @@ type PendingHistory struct { stopCh chan struct{} stopped chan struct{} + // Tenant isolation for DB operations. + tenantID uuid.UUID + // Compaction (optional — nil means no auto-compaction) compactionCfg *CompactionConfig @@ -70,11 +74,12 @@ func NewPendingHistory() *PendingHistory { // NewPersistentHistory creates a persistent history tracker with batched DB flush. // Call StartFlusher() after creation and StopFlusher() on shutdown. -func NewPersistentHistory(channelName string, s store.PendingMessageStore) *PendingHistory { +func NewPersistentHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory { return &PendingHistory{ entries: make(map[string][]HistoryEntry), channelName: channelName, store: s, + tenantID: tenantID, flushSignal: make(chan struct{}, 1), stopCh: make(chan struct{}), stopped: make(chan struct{}), @@ -103,13 +108,22 @@ func (ph *PendingHistory) LoadFromDB(ctx context.Context) { } // MakeHistory creates a PendingHistory — persistent if store is non-nil, RAM-only otherwise. -func MakeHistory(channelName string, s store.PendingMessageStore) *PendingHistory { +func MakeHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory { if s != nil { - return NewPersistentHistory(channelName, s) + return NewPersistentHistory(channelName, s, tenantID) } return NewPendingHistory() } +// tenantCtx returns a context with the tenant ID set for DB operations. +func (ph *PendingHistory) tenantCtx() context.Context { + ctx := context.Background() + if ph.tenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, ph.tenantID) + } + return ctx +} + // Record adds a message to the pending history for a group. // If limit ≤ 0, recording is disabled. func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit int) { @@ -155,7 +169,7 @@ func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit in // populates RAM cache, and returns converted entries. // Called when RAM has no entries but DB store is available. func (ph *PendingHistory) loadFromDB(historyKey string) []HistoryEntry { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second) defer cancel() msgs, err := ph.store.ListByKey(ctx, ph.channelName, historyKey) @@ -270,7 +284,7 @@ func (ph *PendingHistory) Clear(historyKey string) { // Remove pending flushes for this key ph.removeFromFlushBuf(historyKey) // Delete from DB - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second) defer cancel() if err := ph.store.DeleteByKey(ctx, ph.channelName, historyKey); err != nil { slog.Warn("pending_history.clear_db_failed", "channel", ph.channelName, "key", historyKey, "error", err) diff --git a/internal/channels/history_compaction.go b/internal/channels/history_compaction.go index 62349794..5306d499 100644 --- a/internal/channels/history_compaction.go +++ b/internal/channels/history_compaction.go @@ -37,7 +37,7 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg // RAM count may be stale after restart (LoadFromDB doesn't warm full history). // If RAM says below threshold, ask DB for the real count. if currentCount <= threshold { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second) defer cancel() dbCount, err := ph.store.CountByKey(ctx, ph.channelName, historyKey) if err != nil || dbCount <= threshold { @@ -137,7 +137,7 @@ func (ph *PendingHistory) runCompaction(historyKey string, cfg *CompactionConfig // Force-flush buffer to ensure DB is consistent ph.flushNow() - ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 180*time.Second) defer cancel() // Check threshold from DB (may have been cleared since trigger) diff --git a/internal/channels/history_flush.go b/internal/channels/history_flush.go index bd5501a9..b19a5cf5 100644 --- a/internal/channels/history_flush.go +++ b/internal/channels/history_flush.go @@ -93,7 +93,7 @@ func (ph *PendingHistory) flushLoop() { // writeBatch writes a batch of messages to the DB store. func (ph *PendingHistory) writeBatch(batch []store.PendingMessage) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 15*time.Second) defer cancel() if err := ph.store.AppendBatch(ctx, batch); err != nil { diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index 79f38109..07a00d2c 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -9,6 +9,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -23,7 +25,7 @@ type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage msgBus *bus.MessageBus, pairingSvc store.PairingStore) (Channel, error) // InstanceLoader loads channel instances from the database and registers them with the Manager. -// Follows the DynamicToolLoader pattern: LoadAll at startup, Reload on cache invalidation. +// Follows a load-all-at-startup pattern with cache invalidation for reload. type InstanceLoader struct { store store.ChannelInstanceStore agentStore store.AgentStore @@ -234,6 +236,10 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns if base, ok := ch.(interface{ SetType(string) }); ok { base.SetType(inst.ChannelType) } + // Propagate tenant_id from DB instance to channel for tenant-scoped message handling. + if base, ok := ch.(interface{ SetTenantID(uuid.UUID) }); ok { + base.SetTenantID(inst.TenantID) + } // Wire pending message auto-compaction. // Priority: config provider/model > agent's provider/model > fallback. @@ -242,8 +248,9 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns var model string // Try config-level provider/model first. + tctx := store.WithTenantID(ctx, inst.TenantID) if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" { - if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil { + if cp, err := l.providerReg.Get(tctx, l.pendingCompactCfg.Provider); err == nil { p = cp model = l.pendingCompactCfg.Model if model == "" { @@ -253,7 +260,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns } // Fallback: agent's provider/model. if p == nil && ag != nil && ag.Provider != "" { - if ap, err := l.providerReg.Get(ag.Provider); err == nil { + if ap, err := l.providerReg.Get(tctx, ag.Provider); err == nil { p = ap model = ag.Model if model == "" { diff --git a/internal/channels/quota.go b/internal/channels/quota.go index 8a9bcef6..6c9a9684 100644 --- a/internal/channels/quota.go +++ b/internal/channels/quota.go @@ -3,11 +3,14 @@ package channels import ( "context" "database/sql" + "fmt" "log/slog" "sync" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // QuotaResult is returned by QuotaChecker.Check. @@ -222,20 +225,23 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult { dayAgo := now.Add(-24 * time.Hour) weekAgo := now.Add(-7 * 24 * time.Hour) - // Query per-user counts for the past week - rows, err := qc.db.QueryContext(ctx, ` + // Query per-user counts for the past week (tenant-scoped) + tenantFilter, tenantArgs, nextIdx := tenantWhereClause(ctx, 4) + args := append([]any{hourAgo, dayAgo, weekAgo}, tenantArgs...) + rows, err := qc.db.QueryContext(ctx, fmt.Sprintf(` SELECT user_id, COUNT(*) FILTER (WHERE created_at >= $1) AS hour_count, COUNT(*) FILTER (WHERE created_at >= $2) AS day_count, COUNT(*) AS week_count FROM traces - WHERE parent_trace_id IS NULL AND created_at >= $3 + WHERE parent_trace_id IS NULL AND created_at >= $3%s GROUP BY user_id ORDER BY week_count DESC - LIMIT 50`, - hourAgo, dayAgo, weekAgo, + LIMIT 50`, tenantFilter), + args..., ) + _ = nextIdx if err != nil { slog.Warn("quota.usage: failed to query user counts", "error", err) QueryTodaySummary(ctx, qc.db, &result) @@ -272,7 +278,9 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult now := time.Now().UTC() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - err := db.QueryRowContext(ctx, ` + tenantFilter, tenantArgs, _ := tenantWhereClause(ctx, 2) + args := append([]any{startOfDay}, tenantArgs...) + err := db.QueryRowContext(ctx, fmt.Sprintf(` SELECT COUNT(*), COALESCE(SUM(total_input_tokens), 0), @@ -280,14 +288,28 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult COALESCE(SUM(total_cost), 0), COUNT(DISTINCT user_id) FROM traces - WHERE parent_trace_id IS NULL AND created_at >= $1`, - startOfDay, + WHERE parent_trace_id IS NULL AND created_at >= $1%s`, tenantFilter), + args..., ).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday) if err != nil { slog.Warn("quota.usage: failed to query today summary", "error", err) } } +// tenantWhereClause returns a SQL fragment " AND tenant_id = $N" with the tenant UUID arg, +// or empty string if the caller has cross-tenant access. startIdx is the next $N placeholder. +func tenantWhereClause(ctx context.Context, startIdx int) (string, []any, int) { + if store.IsCrossTenant(ctx) { + return "", nil, startIdx + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + // Fail-closed: no tenant = filter to impossible value + return " AND tenant_id = '00000000-0000-0000-0000-000000000000'", nil, startIdx + } + return fmt.Sprintf(" AND tenant_id = $%d", startIdx), []any{tid}, startIdx + 1 +} + // cleanupLoop periodically evicts stale cache entries. func (qc *QuotaChecker) cleanupLoop() { ticker := time.NewTicker(2 * time.Minute) diff --git a/internal/channels/slack/channel.go b/internal/channels/slack/channel.go index 8e965847..4c6954f3 100644 --- a/internal/channels/slack/channel.go +++ b/internal/channels/slack/channel.go @@ -122,7 +122,7 @@ func New(cfg config.SlackConfig, msgBus *bus.MessageBus, pairingSvc store.Pairin config: cfg, requireMention: requireMention, pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore, base.TenantID()), historyLimit: historyLimit, debounceDelay: debounceDelay, threadTTL: threadTTL, diff --git a/internal/channels/slack/handlers.go b/internal/channels/slack/handlers.go index 92cbc5a6..fa165abb 100644 --- a/internal/channels/slack/handlers.go +++ b/internal/channels/slack/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/slack-go/slack/socketmode" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" ) func (c *Channel) handleEventsAPI(evt socketmode.Event) { @@ -32,6 +33,8 @@ func (c *Channel) handleEventsAPI(evt socketmode.Event) { } func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) // For message_changed: extract user/text from the nested Message field. // Only process if the edit introduces a new @bot mention. if ev.SubType == "message_changed" { @@ -89,11 +92,11 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { // Policy check if isDM { - if !c.checkDMPolicy(senderID, channelID) { + if !c.checkDMPolicy(ctx, senderID, channelID) { return } } else { - if !c.checkGroupPolicy(senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID) { return } } @@ -196,7 +199,7 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, displayName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, "", "group") } slog.Debug("slack group message recorded (no mention)", diff --git a/internal/channels/slack/handlers_mention.go b/internal/channels/slack/handlers_mention.go index 2a0ba6f7..62b6ecd3 100644 --- a/internal/channels/slack/handlers_mention.go +++ b/internal/channels/slack/handlers_mention.go @@ -1,6 +1,7 @@ package slack import ( + "context" "fmt" "log/slog" "strings" @@ -10,9 +11,12 @@ import ( "github.com/slack-go/slack/slackevents" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" ) func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) if ev.User == c.botUserID || ev.User == "" { return } @@ -35,7 +39,7 @@ func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { displayName := strings.ReplaceAll(c.resolveDisplayName(senderID), "|", "_") compoundSenderID := fmt.Sprintf("%s|%s", senderID, displayName) - if !c.checkGroupPolicy(senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID) { return } @@ -115,7 +119,7 @@ func (c *Channel) stripBotMention(text string) string { // --- Policy checks --- -func (c *Channel) checkDMPolicy(senderID, channelID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -130,7 +134,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { return c.HasAllowList() && c.IsAllowed(senderID) default: // "pairing" if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(senderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -143,12 +147,12 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { if c.HasAllowList() && c.IsAllowed(senderID) { return true } - c.sendPairingReply(senderID, channelID) + c.sendPairingReply(ctx, senderID, channelID) return false } } -func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -172,7 +176,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { } groupSenderID := fmt.Sprintf("group:%s", channelID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -183,14 +187,14 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { return true } } - c.sendPairingReply(groupSenderID, channelID) + c.sendPairingReply(ctx, groupSenderID, channelID) return false default: // "open" return true } } -func (c *Channel) sendPairingReply(senderID, channelID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) { if c.pairingService == nil { return } @@ -201,7 +205,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil) if err != nil { slog.Warn("slack: failed to request pairing code", "error", err) return diff --git a/internal/channels/slack/utils.go b/internal/channels/slack/utils.go index 307095c0..da639a61 100644 --- a/internal/channels/slack/utils.go +++ b/internal/channels/slack/utils.go @@ -9,6 +9,7 @@ import ( "time" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // HandleMessage overrides BaseChannel to allow messages when the chatID (Slack channel) @@ -32,7 +33,8 @@ func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []s // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind) + ctx := store.WithTenantID(context.Background(), c.TenantID()) + cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind) } c.Bus().PublishInbound(bus.InboundMessage{ diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index ea6b61ad..4c328f90 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -122,7 +122,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai agentStore: agentStore, configPermStore: configPermStore, teamStore: teamStore, - groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore, base.TenantID()), historyLimit: historyLimit, requireMention: requireMention, }, nil diff --git a/internal/channels/telegram/commands_pairing.go b/internal/channels/telegram/commands_pairing.go index 9ff8446a..9a003495 100644 --- a/internal/channels/telegram/commands_pairing.go +++ b/internal/channels/telegram/commands_pairing.go @@ -36,7 +36,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, chatID int64, userID, us } meta := map[string]string{"username": username} - code, err := c.pairingService.RequestPairing(userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta) + code, err := c.pairingService.RequestPairing(ctx, userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta) if err != nil { slog.Debug("pairing request failed", "user_id", userID, "error", err) return @@ -70,7 +70,7 @@ func (c *Channel) sendGroupPairingReply(ctx context.Context, chatID int64, chatI if chatTitle != "" { meta = map[string]string{"chat_title": chatTitle} } - code, err := c.pairingService.RequestPairing(groupSenderID, c.Name(), localKey, "default", meta) + code, err := c.pairingService.RequestPairing(ctx, groupSenderID, c.Name(), localKey, "default", meta) if err != nil { slog.Debug("group pairing request failed", "chat_id", chatIDStr, "error", err) return diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 1d9f06d7..4e796635 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -148,8 +148,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { default: // "pairing" or unknown → secure default paired := false if c.pairingService != nil { - p1, err1 := c.pairingService.IsPaired(userID, c.Name()) - p2, err2 := c.pairingService.IsPaired(senderID, c.Name()) + p1, err1 := c.pairingService.IsPaired(ctx, userID, c.Name()) + p2, err2 := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err1 != nil || err2 != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "user_id", userID, "channel", c.Name(), "err1", err1, "err2", err2) @@ -256,7 +256,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { if topicCfg.groupPolicy == "pairing" && c.pairingService != nil { if _, cached := c.approvedGroups.Load(chatIDStr); !cached { groupSenderID := fmt.Sprintf("group:%d", chatID) - paired, pairErr := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, pairErr := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if pairErr != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", pairErr) @@ -295,7 +295,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { if isGroup && topicCfg.groupPolicy == "pairing" && c.pairingService != nil { if _, cached := c.approvedGroups.Load(chatIDStr); !cached { groupSenderID := fmt.Sprintf("group:%d", chatID) - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 66e10e10..582bb5d2 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -204,6 +204,8 @@ func (c *Channel) listenLoop() { // handleIncomingMessage processes a message received from the bridge. // Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]} func (c *Channel) handleIncomingMessage(msg map[string]any) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID, ok := msg["from"].(string) if !ok || senderID == "" { return @@ -222,11 +224,11 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { // DM/Group policy check if peerKind == "direct" { - if !c.checkDMPolicy(senderID, chatID) { + if !c.checkDMPolicy(ctx, senderID, chatID) { return } } else { - if !c.checkGroupPolicy(senderID, chatID) { + if !c.checkGroupPolicy(ctx, senderID, chatID) { slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID) return } @@ -269,14 +271,14 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { // Collect contact for processed messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind) } c.HandleMessage(senderID, chatID, content, media, metadata, peerKind) } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. -func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -296,7 +298,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { } groupSenderID := fmt.Sprintf("group:%s", chatID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -307,7 +309,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { return true } } - c.sendPairingReply(groupSenderID, chatID) + c.sendPairingReply(ctx, groupSenderID, chatID) return false default: // "open" return true @@ -315,7 +317,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { } // checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -336,7 +338,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -351,13 +353,13 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { return true } - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } // sendPairingReply sends a pairing code to the user via the WS bridge. -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { return } @@ -369,7 +371,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go index 98d39d9b..07dd74ed 100644 --- a/internal/channels/zalo/personal/channel.go +++ b/internal/channels/zalo/personal/channel.go @@ -64,7 +64,7 @@ func New(cfg config.ZaloPersonalConfig, msgBus *bus.MessageBus, pairingSvc store BaseChannel: base, config: cfg, pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore, base.TenantID()), historyLimit: historyLimit, requireMention: requireMention, stopCh: make(chan struct{}), diff --git a/internal/channels/zalo/personal/handlers.go b/internal/channels/zalo/personal/handlers.go index 5ecb1614..4cefe8ac 100644 --- a/internal/channels/zalo/personal/handlers.go +++ b/internal/channels/zalo/personal/handlers.go @@ -15,6 +15,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) @@ -32,6 +33,8 @@ func (c *Channel) handleMessage(msg protocol.Message) { } func (c *Channel) handleDM(msg protocol.UserMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.Data.UIDFrom threadID := msg.ThreadID() @@ -40,7 +43,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { return } - if !c.checkDMPolicy(senderID, threadID) { + if !c.checkDMPolicy(ctx, senderID, threadID) { return } @@ -61,7 +64,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { // Collect contact for DM messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "direct") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "direct") } metadata := map[string]string{ @@ -72,6 +75,8 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { } func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.Data.UIDFrom threadID := msg.ThreadID() @@ -81,7 +86,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { } // Step 1: enforce access policy (allowlist/pairing). Hard reject — don't record history. - if !c.checkGroupPolicy(senderID, threadID) { + if !c.checkGroupPolicy(ctx, senderID, threadID) { return } @@ -105,7 +110,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group") } slog.Debug("zalo_personal group message recorded (no mention)", @@ -138,7 +143,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { // Collect contact for group-mentioned messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group") } metadata := map[string]string{ diff --git a/internal/channels/zalo/personal/policy.go b/internal/channels/zalo/personal/policy.go index 6dcf31db..04d4c5fb 100644 --- a/internal/channels/zalo/personal/policy.go +++ b/internal/channels/zalo/personal/policy.go @@ -13,7 +13,7 @@ import ( const pairingDebounce = 60 * time.Second // checkDMPolicy enforces DM policy for incoming messages. -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "allowlist" @@ -37,7 +37,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -52,12 +52,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { return true } - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { sess := c.session() if c.pairingService == nil || sess == nil { return @@ -70,7 +70,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("zalo_personal pairing request failed", "sender_id", senderID, "error", err) return @@ -98,7 +98,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { // checkGroupPolicy enforces group access policy (allowlist/pairing). // Returns false if the group is blocked by policy; does NOT check @mention gating. -func (c *Channel) checkGroupPolicy(senderID, groupID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, groupID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "allowlist" @@ -124,7 +124,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool { groupSenderID := fmt.Sprintf("group:%s", groupID) paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -135,7 +135,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool { if paired { c.approvedGroups.Store(groupID, true) } else { - c.sendPairingReply(groupSenderID, groupID) + c.sendPairingReply(ctx, groupSenderID, groupID) return false } } diff --git a/internal/channels/zalo/personal/zalomethods/qr.go b/internal/channels/zalo/personal/zalomethods/qr.go index 27a66f54..9fe5266c 100644 --- a/internal/channels/zalo/personal/zalomethods/qr.go +++ b/internal/channels/zalo/personal/zalomethods/qr.go @@ -110,7 +110,7 @@ func (m *QRMethods) runQRFlow(ctx context.Context, client *gateway.Client, insta return } - if err := m.instanceStore.Update(context.Background(), instanceID, map[string]any{ + if err := m.instanceStore.Update(ctx, instanceID, map[string]any{ "credentials": string(credsJSON), }); err != nil { slog.Error("Zalo Personal QR: save credentials failed", "instance", instanceIDStr, "error", err) diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index 88279a4b..2357f117 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -186,6 +186,8 @@ func (c *Channel) processUpdate(update zaloUpdate) { } func (c *Channel) handleTextMessage(msg *zaloMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.From.ID if senderID == "" { slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID) @@ -197,7 +199,7 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) { } // DM policy enforcement (Zalo is DM-only) - if !c.checkDMPolicy(senderID, chatID) { + if !c.checkDMPolicy(ctx, senderID, chatID) { return } @@ -221,6 +223,8 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) { } func (c *Channel) handleImageMessage(msg *zaloMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.From.ID if senderID == "" { slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID) @@ -231,7 +235,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) { chatID = senderID } - if !c.checkDMPolicy(senderID, chatID) { + if !c.checkDMPolicy(ctx, senderID, chatID) { return } @@ -278,7 +282,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) { // --- DM Policy --- -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { switch c.dmPolicy { case "disabled": slog.Debug("zalo message rejected: DMs disabled", "sender_id", senderID) @@ -298,7 +302,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { // Check if already paired or in allowlist paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -314,12 +318,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { } // Send pairing reply (debounced) - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { return } @@ -331,7 +335,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("zalo pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/config/tenant_paths.go b/internal/config/tenant_paths.go new file mode 100644 index 00000000..495887ac --- /dev/null +++ b/internal/config/tenant_paths.go @@ -0,0 +1,58 @@ +package config + +import ( + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// masterTenantID mirrors store.MasterTenantID. +// Duplicated here to avoid an import cycle (store imports config). +var masterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + +// TenantDataDir returns the data directory root for a tenant. +// Master tenant returns dataDir unchanged (backward compat). +// Other tenants return dataDir/tenants/{slug}/. +func TenantDataDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string { + if tenantID == masterTenantID { + return dataDir + } + result := filepath.Join(dataDir, "tenants", tenantSlug) + // Defense-in-depth: prevent path traversal via malicious slug. + tenantsBase := filepath.Join(dataDir, "tenants") + string(filepath.Separator) + if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) { + return filepath.Join(dataDir, "tenants", tenantID.String()) + } + return result +} + +// TenantWorkspace returns the workspace root for a tenant. +// Master tenant returns workspace unchanged (backward compat). +// Other tenants return workspace/tenants/{slug}/. +func TenantWorkspace(workspace string, tenantID uuid.UUID, tenantSlug string) string { + if tenantID == masterTenantID { + return workspace + } + result := filepath.Join(workspace, "tenants", tenantSlug) + tenantsBase := filepath.Join(workspace, "tenants") + string(filepath.Separator) + if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) { + return filepath.Join(workspace, "tenants", tenantID.String()) + } + return result +} + +// TenantTeamDir returns the team workspace directory for a tenant. +func TenantTeamDir(dataDir string, tenantID uuid.UUID, tenantSlug string, teamID uuid.UUID) string { + return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "teams", teamID.String()) +} + +// TenantSkillsStoreDir returns the managed skills directory for a tenant. +func TenantSkillsStoreDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string { + return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "skills-store") +} + +// TenantMediaDir returns the media storage directory for a tenant. +func TenantMediaDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string { + return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "media") +} diff --git a/internal/gateway/client.go b/internal/gateway/client.go index b76d006d..837e338f 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -34,6 +34,14 @@ type Client struct { pairingPending bool // true while waiting for admin approval pairedSenderID string // senderID used for browser pairing auth (for revocation lookup) pairedChannel string // channel used for pairing auth (e.g., "browser") + + // Team access cache for event filtering (lazily populated). + teamIDs map[string]bool + + tenantID uuid.UUID // resolved tenant (uuid.Nil = cross-tenant) + crossTenant bool // true for owner/system admin + tenantName string // resolved tenant display name (set during connect) + tenantSlug string // resolved tenant URL slug (set during connect) } func NewClient(conn *websocket.Conn, server *Server, remoteIP string) *Client { @@ -192,6 +200,43 @@ func (c *Client) ConnectedAt() time.Time { return c.connectedAt } // RemoteAddr returns the peer IP:port. func (c *Client) RemoteAddr() string { return c.remoteAddr } +// TenantID returns the resolved tenant UUID (uuid.Nil means cross-tenant). +func (c *Client) TenantID() uuid.UUID { return c.tenantID } + +// IsCrossTenant returns true if the client has cross-tenant (owner/system admin) access. +func (c *Client) IsCrossTenant() bool { return c.crossTenant } + +// HasScope reports whether the client has the given scope. +func (c *Client) HasScope(scope permissions.Scope) bool { + for _, s := range c.scopes { + if s == scope { + return true + } + } + return false +} + +// hasTeamAccess checks if the client has access to a team (for event filtering). +// Returns true for admin role. For others, checks the lazily-populated teamIDs cache. +// TODO: populate teamIDs from team_user_grants on connect or first team event. +func (c *Client) hasTeamAccess(teamID string) bool { + if permissions.HasMinRole(c.role, permissions.RoleAdmin) { + return true + } + if c.teamIDs == nil { + return false + } + return c.teamIDs[teamID] +} + +// SetTeamAccess sets the team access cache for this client. +func (c *Client) SetTeamAccess(teamIDs []string) { + c.teamIDs = make(map[string]bool, len(teamIDs)) + for _, id := range teamIDs { + c.teamIDs[id] = true + } +} + // Close shuts down the client connection. func (c *Client) Close() { close(c.send) diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go new file mode 100644 index 00000000..53d6c634 --- /dev/null +++ b/internal/gateway/event_filter.go @@ -0,0 +1,208 @@ +package gateway + +import ( + "encoding/json" + "strings" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// clientCanReceiveEvent checks whether a WS client should receive a given bus event. +// Admin clients receive all events. Non-admin clients are filtered by user/team scope. +func clientCanReceiveEvent(c *Client, event bus.Event) bool { + // Internal events are never forwarded. + if strings.HasPrefix(event.Name, "cache.") || event.Name == protocol.EventAuditLog { + return false + } + + // System-wide events go to everyone. + if isSystemEvent(event.Name) { + return true + } + + // Tenant isolation: fail-closed, 3-mode filtering. + // + // Mode 1: Unscoped admin (crossTenant=true, tenantID=Nil) → see everything + // Mode 2: Scoped admin (crossTenant=true, tenantID=X) → tenant X events + unscoped system events + // Mode 3: Regular user (crossTenant=false, tenantID=X) → ONLY tenant X events (fail-closed) + if !c.crossTenant && c.tenantID == uuid.Nil { + return false // fail-closed: no tenant assigned to non-admin client + } else if c.crossTenant && c.tenantID == uuid.Nil { + // Mode 1: unscoped cross-tenant admin → no tenant filtering + } else if c.tenantID != uuid.Nil { + // Event has explicit tenant → must match client's tenant + if event.TenantID != uuid.Nil && event.TenantID != c.tenantID { + return false + } + // Event has no tenant → only cross-tenant admin (scoped) can see unscoped events + if event.TenantID == uuid.Nil && !c.crossTenant { + return false // fail-closed: regular users blocked from unscoped events + } + } + + // Admin sees everything (when not tenant-scoped, handled above). + if permissions.HasMinRole(c.role, permissions.RoleAdmin) { + return true + } + + // Agent / chat events: filter by UserID. + if event.Name == protocol.EventAgent || event.Name == protocol.EventChat { + if uid := extractEventUserID(event); uid != "" { + return uid == c.userID + } + return true // no routing context → broadcast (legacy) + } + + // Session events: filter by UserID in payload. + if event.Name == protocol.EventSessionUpdated { + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Cron events: filter by UserID in payload. + if event.Name == protocol.EventCron { + if ce, ok := event.Payload.(store.CronEvent); ok && ce.UserID != "" { + return ce.UserID == c.userID + } + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Trace events: filter by UserID. + if event.Name == protocol.EventTraceUpdated { + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Team events: filter by TeamID. + if strings.HasPrefix(event.Name, "team.") || strings.HasPrefix(event.Name, "delegation.") { + if tid := extractTeamID(event); tid != "" { + return c.hasTeamAccess(tid) + } + return true // no team context → broadcast + } + + // Tenant access revocation: deliver to the affected user only. + if event.Name == protocol.EventTenantAccessRevoked { + if uid := extractMapField(event.Payload, "user_id"); uid != "" { + return uid == c.userID + } + return false + } + + // Admin-only events: pairing, node, agent links. + if isAdminOnlyEvent(event.Name) { + return false // non-admin clients don't receive these + } + + // Exec approval events: scoped to the requesting user. + if strings.HasPrefix(event.Name, "exec.approval.") { + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Zalo personal QR events: admin-only (channel management). + if strings.HasPrefix(event.Name, "zalo.personal.") { + return false + } + + // Skill dep events → broadcast (non-sensitive, skill names only). + if strings.HasPrefix(event.Name, "skill.") { + return true + } + + // Default: deny unknown events to non-admin (fail-closed). + return false +} + +// isSystemEvent returns true for events that should be broadcast to all clients. +func isSystemEvent(name string) bool { + switch name { + case protocol.EventHealth, protocol.EventPresence, protocol.EventVoicewakeChanged, + protocol.EventTick, protocol.EventShutdown, protocol.EventConnectChallenge, + protocol.EventTalkMode, protocol.EventHeartbeat: + return true + } + return false +} + +// isAdminOnlyEvent returns true for events that only admin clients should receive. +func isAdminOnlyEvent(name string) bool { + switch name { + case protocol.EventNodePairRequested, protocol.EventNodePairResolved, + protocol.EventDevicePairReq, protocol.EventDevicePairRes, + protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted, + protocol.EventWorkspaceFileChanged: + return true + } + return false +} + +// extractEventUserID extracts UserID from agent.AgentEvent payload. +func extractEventUserID(event bus.Event) string { + switch ae := event.Payload.(type) { + case agent.AgentEvent: + return ae.UserID + case *agent.AgentEvent: + return ae.UserID + } + return extractMapField(event.Payload, "userId") +} + +// extractTeamID extracts TeamID from team event payloads. +func extractTeamID(event bus.Event) string { + switch te := event.Payload.(type) { + case protocol.TeamTaskEventPayload: + return te.TeamID + case *protocol.TeamTaskEventPayload: + return te.TeamID + } + // Fallback: check map payloads. + if tid := extractMapField(event.Payload, "team_id"); tid != "" { + return tid + } + return extractMapField(event.Payload, "teamId") +} + +// extractMapField extracts a string field from a map[string]any or map[string]string payload. +// Falls back to JSON unmarshaling for struct payloads. +func extractMapField(payload any, key string) string { + switch m := payload.(type) { + case map[string]any: + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + case map[string]string: + return m[key] + default: + // Try JSON round-trip for struct payloads (lazy, used rarely). + data, err := json.Marshal(payload) + if err != nil { + return "" + } + var parsed map[string]any + if json.Unmarshal(data, &parsed) == nil { + if v, ok := parsed[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + } + return "" +} diff --git a/internal/gateway/methods/access.go b/internal/gateway/methods/access.go new file mode 100644 index 00000000..566f1817 --- /dev/null +++ b/internal/gateway/methods/access.go @@ -0,0 +1,18 @@ +package methods + +import ( + "slices" + + "github.com/nextlevelbuilder/goclaw/internal/permissions" +) + +// canSeeAll checks if user has full data visibility (admin role OR owner user). +func canSeeAll(role permissions.Role, ownerIDs []string, userID string) bool { + if permissions.HasMinRole(role, permissions.RoleAdmin) { + return true + } + if userID != "" && slices.Contains(ownerIDs, userID) { + return true + } + return false +} diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go index b040e33b..290bf5fc 100644 --- a/internal/gateway/methods/agent_links.go +++ b/internal/gateway/methods/agent_links.go @@ -59,7 +59,7 @@ func (m *AgentLinksMethods) handleList(ctx context.Context, client *gateway.Clie return } - agentID, err := resolveAgentUUID(m.agentStore, params.AgentID) + agentID, err := resolveAgentUUID(ctx, m.agentStore, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -133,12 +133,12 @@ func (m *AgentLinksMethods) handleCreate(ctx context.Context, client *gateway.Cl return } - sourceAgent, err := resolveAgentInfo(m.agentStore, params.SourceAgent) + sourceAgent, err := resolveAgentInfo(ctx, m.agentStore, params.SourceAgent) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source agent: "+err.Error())) return } - targetAgent, err := resolveAgentInfo(m.agentStore, params.TargetAgent) + targetAgent, err := resolveAgentInfo(ctx, m.agentStore, params.TargetAgent) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "target agent: "+err.Error())) return @@ -408,15 +408,15 @@ func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, source // --- helpers --- -func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { +func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { if id, err := uuid.Parse(keyOrID); err == nil { - ag, err := agentStore.GetByID(context.Background(), id) + ag, err := agentStore.GetByID(ctx, id) if err != nil { return uuid.Nil, err } return ag.ID, nil } - ag, err := agentStore.GetByKey(context.Background(), keyOrID) + ag, err := agentStore.GetByKey(ctx, keyOrID) if err != nil { return uuid.Nil, err } @@ -424,9 +424,9 @@ func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, e } // resolveAgentInfo returns full agent data for validation and cache invalidation. -func resolveAgentInfo(agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) { +func resolveAgentInfo(ctx context.Context, agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) { if id, err := uuid.Parse(keyOrID); err == nil { - return agentStore.GetByID(context.Background(), id) + return agentStore.GetByID(ctx, id) } - return agentStore.GetByKey(context.Background(), keyOrID) + return agentStore.GetByKey(ctx, keyOrID) } diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index dca94bac..5424a6c7 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -4,13 +4,13 @@ import ( "context" "encoding/json" "log/slog" - "slices" "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -34,10 +34,7 @@ func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspa // isOwnerUser checks if the given user ID is in the configured owner IDs. func (m *AgentsMethods) isOwnerUser(userID string) bool { - if userID == "" { - return false - } - return slices.Contains(m.cfg.Gateway.OwnerIDs, userID) + return canSeeAll(permissions.RoleViewer, m.cfg.Gateway.OwnerIDs, userID) } func (m *AgentsMethods) Register(router *gateway.MethodRouter) { @@ -57,7 +54,7 @@ type agentParams struct { AgentID string `json:"agentId"` } -func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleAgent(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params agentParams if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -66,7 +63,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r params.AgentID = "default" } - loop, err := m.agents.Get(params.AgentID) + loop, err := m.agents.Get(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -78,7 +75,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r })) } -func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleAgentWait(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params agentParams if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -87,7 +84,7 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien params.AgentID = "default" } - loop, err := m.agents.Get(params.AgentID) + loop, err := m.agents.Get(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go index dd19758f..67815c1d 100644 --- a/internal/gateway/methods/agents_create.go +++ b/internal/gateway/methods/agents_create.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" @@ -28,6 +30,7 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client Avatar string `json:"avatar"` AgentType string `json:"agent_type"` // "open" (default) or "predefined" OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system" + TenantID string `json:"tenant_id"` // required for cross-tenant callers; ignored otherwise // Per-agent config overrides ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` @@ -67,7 +70,6 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client if m.agentStore != nil { // --- DB-backed: create agent in store --- - ctx := context.Background() // Check if agent already exists in DB if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil { @@ -82,10 +84,28 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client ownerID = params.OwnerIDs[0] } + // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant. + var tenantID uuid.UUID + if client.IsCrossTenant() { + if params.TenantID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "tenant_id"))) + return + } + tid, err := uuid.Parse(params.TenantID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))) + return + } + tenantID = tid + } else { + tenantID = client.TenantID() + } + agentData := &store.AgentData{ AgentKey: agentID, DisplayName: params.Name, OwnerID: ownerID, + TenantID: tenantID, AgentType: agentType, Provider: m.cfg.Agents.Defaults.Provider, Model: m.cfg.Agents.Defaults.Model, diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index 1928d4d5..6e3768aa 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -31,6 +31,12 @@ func (s *createCaptureStore) GetByKey(_ context.Context, _ string) (*store.Agent func (s *createCaptureStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *createCaptureStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { + return nil, nil +} +func (s *createCaptureStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) { + return nil, nil +} func (s *createCaptureStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go index 039ed9ef..cd3f6f1b 100644 --- a/internal/gateway/methods/agents_delete.go +++ b/internal/gateway/methods/agents_delete.go @@ -40,7 +40,6 @@ func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client if m.agentStore != nil { // --- DB-backed: delete from store --- - ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go index c6058209..d0dd2ac5 100644 --- a/internal/gateway/methods/agents_files.go +++ b/internal/gateway/methods/agents_files.go @@ -38,7 +38,7 @@ func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Cli if m.agentStore != nil { // --- DB-backed: list from store --- - ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) @@ -137,7 +137,7 @@ func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Clie if m.agentStore != nil { // --- DB-backed: read from store --- - ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) @@ -236,7 +236,7 @@ func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Clie if m.agentStore != nil { // --- DB-backed: write to store --- - ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) diff --git a/internal/gateway/methods/agents_identity.go b/internal/gateway/methods/agents_identity.go index 8bd361e4..403e6899 100644 --- a/internal/gateway/methods/agents_identity.go +++ b/internal/gateway/methods/agents_identity.go @@ -15,7 +15,7 @@ import ( // --- agent.identity.get --- // Matching TS src/gateway/server-methods/agent.ts:601-643 -func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleIdentityGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { AgentID string `json:"agentId"` SessionKey string `json:"sessionKey"` @@ -42,7 +42,6 @@ func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Cli if m.agentStore != nil { // --- DB-backed: read identity from store --- - ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err == nil { result["name"] = ag.DisplayName diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go index 2172ba91..9dee9639 100644 --- a/internal/gateway/methods/agents_update.go +++ b/internal/gateway/methods/agents_update.go @@ -46,7 +46,6 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client if m.agentStore != nil { // --- DB-backed: update agent in store --- - ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) diff --git a/internal/gateway/methods/api_keys.go b/internal/gateway/methods/api_keys.go index cb816788..1ca6ec93 100644 --- a/internal/gateway/methods/api_keys.go +++ b/internal/gateway/methods/api_keys.go @@ -35,7 +35,12 @@ func (m *APIKeysMethods) Register(router *gateway.MethodRouter) { func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - keys, err := m.apiKeys.List(ctx) + // Non-admin callers only see their own keys. + ownerID := "" + if client.Role() != permissions.RoleAdmin { + ownerID = client.UserID() + } + keys, err := m.apiKeys.List(ctx, ownerID) if err != nil { slog.Error("api_keys.list failed", "error", err) client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys"))) @@ -54,6 +59,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien Name string `json:"name"` Scopes []string `json:"scopes"` ExpiresIn *int `json:"expires_in"` // seconds; nil = never + OwnerID string `json:"owner_id"` // optional; non-admin callers always get their own user_id + TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key) } if req.Params != nil { if err := json.Unmarshal(req.Params, ¶ms); err != nil { @@ -79,6 +86,12 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien } } + // Non-admin callers always bind the key to their own user_id. + ownerID := params.OwnerID + if client.Role() != permissions.RoleAdmin { + ownerID = client.UserID() + } + raw, hash, prefix, err := crypto.GenerateAPIKey() if err != nil { slog.Error("api_keys.generate failed", "error", err) @@ -86,6 +99,34 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien return } + // Resolve tenant_id based on caller type. + var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB) + if client.IsCrossTenant() { + if params.TenantID != "" { + tid, err := uuid.Parse(params.TenantID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))) + return + } + tenantID = tid + } + // else: uuid.Nil stays → system-level key + } else if client.HasScope(permissions.ScopeProvision) { + // Provision-scoped callers may create tenant-bound keys only (not system-level). + if params.TenantID != "" { + tid, err := uuid.Parse(params.TenantID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))) + return + } + tenantID = tid + } else { + tenantID = client.TenantID() + } + } else { + tenantID = client.TenantID() + } + now := time.Now() key := &store.APIKeyData{ ID: store.GenNewID(), @@ -93,6 +134,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien Prefix: prefix, KeyHash: hash, Scopes: params.Scopes, + OwnerID: ownerID, + TenantID: tenantID, CreatedBy: store.UserIDFromContext(ctx), CreatedAt: now, UpdatedAt: now, @@ -139,7 +182,13 @@ func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Clien return } - if err := m.apiKeys.Revoke(ctx, id); err != nil { + // Non-admin callers can only revoke their own keys. + ownerID := "" + if client.Role() != permissions.RoleAdmin { + ownerID = client.UserID() + } + + if err := m.apiKeys.Revoke(ctx, id, ownerID); err != nil { slog.Error("api_keys.revoke failed", "error", err, "id", params.ID) client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID))) return diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index d670c6a3..bc981df4 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -10,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" @@ -22,11 +23,11 @@ import ( // ChatMethods handles chat.send, chat.history, chat.abort, chat.inject. type ChatMethods struct { - agents *agent.Router - sessions store.SessionStore - rateLimiter *gateway.RateLimiter - eventBus bus.EventPublisher - postTurn tools.PostTurnProcessor + agents *agent.Router + sessions store.SessionStore + rateLimiter *gateway.RateLimiter + eventBus bus.EventPublisher + postTurn tools.PostTurnProcessor } func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods { @@ -114,7 +115,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re } } - loop, err := m.agents.Get(params.AgentID) + loop, err := m.agents.Get(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -222,24 +223,25 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re } // Auto-generate conversation title on first message (label empty = never titled). - if label := m.sessions.GetLabel(sessionKey); label == "" { + if label := m.sessions.GetLabel(ctx, sessionKey); label == "" { agentProvider := loop.Provider() agentModel := loop.Model() userMsg := params.Message + // Use runCtxBase (WithoutCancel + tenant-aware) so title save uses correct tenant. + titleCtx := runCtxBase go func() { - title := agent.GenerateTitle(context.Background(), agentProvider, agentModel, userMsg) + title := agent.GenerateTitle(titleCtx, agentProvider, agentModel, userMsg) if title == "" { return } - m.sessions.SetLabel(sessionKey, title) - if err := m.sessions.Save(sessionKey); err != nil { + m.sessions.SetLabel(titleCtx, sessionKey, title) + if err := m.sessions.Save(titleCtx, sessionKey); err != nil { slog.Warn("failed to save session title", "sessionKey", sessionKey, "error", err) return } - m.eventBus.Broadcast(bus.Event{ - Name: protocol.EventSessionUpdated, - Payload: map[string]string{"sessionKey": sessionKey, "label": title}, - }) + bus.BroadcastForTenant(m.eventBus, protocol.EventSessionUpdated, + client.TenantID(), + map[string]string{"sessionKey": sessionKey, "label": title, "userId": userID}) }() } @@ -277,7 +279,12 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client, sessionKey = sessions.BuildWSSessionKey(params.AgentID, uuid.NewString()) } - history := m.sessions.GetHistory(sessionKey) + history := m.sessions.GetHistory(ctx, sessionKey) + + // Sign file URLs before delivery — sessions store clean paths. + for i := range history { + history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey()) + } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "messages": history, @@ -320,7 +327,7 @@ func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client, // Create an assistant message with gateway-injected metadata messageID := uuid.NewString() - m.sessions.AddMessage(params.SessionKey, providers.Message{ + m.sessions.AddMessage(ctx, params.SessionKey, providers.Message{ Role: "assistant", Content: text, }) diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index d8dcf46a..1a2dbf02 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -29,10 +29,25 @@ func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.Con } func (m *ConfigMethods) Register(router *gateway.MethodRouter) { - router.Register(protocol.MethodConfigGet, m.handleGet) - router.Register(protocol.MethodConfigApply, m.handleApply) - router.Register(protocol.MethodConfigPatch, m.handlePatch) - router.Register(protocol.MethodConfigSchema, m.handleSchema) + router.Register(protocol.MethodConfigGet, m.requireCrossTenant(m.handleGet)) + router.Register(protocol.MethodConfigApply, m.requireCrossTenant(m.handleApply)) + router.Register(protocol.MethodConfigPatch, m.requireCrossTenant(m.handlePatch)) + router.Register(protocol.MethodConfigSchema, m.requireCrossTenant(m.handleSchema)) +} + +// requireCrossTenant wraps a handler to only allow cross-tenant (owner/system) users. +func (m *ConfigMethods) requireCrossTenant(next gateway.MethodHandler) gateway.MethodHandler { + return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if !client.IsCrossTenant() { + locale := store.LocaleFromContext(ctx) + client.SendResponse(protocol.NewErrorResponse( + req.ID, protocol.ErrUnauthorized, + i18n.T(locale, i18n.MsgPermissionDenied, req.Method), + )) + return + } + next(ctx, client, req) + } } func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { diff --git a/internal/gateway/methods/cron.go b/internal/gateway/methods/cron.go index 92d6b7ac..4969375e 100644 --- a/internal/gateway/methods/cron.go +++ b/internal/gateway/methods/cron.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -19,10 +20,11 @@ var cronSlugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) type CronMethods struct { service store.CronStore eventBus bus.EventPublisher + cfg *config.Config } -func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher) *CronMethods { - return &CronMethods{service: service, eventBus: eventBus} +func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher, cfg *config.Config) *CronMethods { + return &CronMethods{service: service, eventBus: eventBus, cfg: cfg} } func (m *CronMethods) Register(router *gateway.MethodRouter) { @@ -36,7 +38,7 @@ func (m *CronMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodCronRuns, m.handleRuns) } -func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *CronMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { IncludeDisabled bool `json:"includeDisabled"` } @@ -44,7 +46,11 @@ func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req json.Unmarshal(req.Params, ¶ms) } - jobs := m.service.ListJobs(params.IncludeDisabled, "", "") + userID := "" + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + userID = client.UserID() + } + jobs := m.service.ListJobs(ctx, params.IncludeDisabled, "", userID) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "jobs": jobs, @@ -80,7 +86,7 @@ func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client, return } - job, err := m.service.AddJob(params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID()) + job, err := m.service.AddJob(ctx, params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID()) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -106,7 +112,15 @@ func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client, return } - if err := m.service.RemoveJob(params.JobID); err != nil { + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + job, ok := m.service.GetJob(ctx, params.JobID) + if !ok || job.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + + if err := m.service.RemoveJob(ctx, params.JobID); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -132,7 +146,15 @@ func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client, return } - if err := m.service.EnableJob(params.JobID, params.Enabled); err != nil { + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + job, ok := m.service.GetJob(ctx, params.JobID) + if !ok || job.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + + if err := m.service.EnableJob(ctx, params.JobID, params.Enabled); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -168,7 +190,15 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client, return } - job, err := m.service.UpdateJob(jobID, params.Patch) + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + existing, ok := m.service.GetJob(ctx, jobID) + if !ok || existing.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + + job, err := m.service.UpdateJob(ctx, jobID, params.Patch) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -203,12 +233,19 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req force := params.Mode == "force" // Validate job exists before responding - _, ok := m.service.GetJob(jobID) + job, ok := m.service.GetJob(ctx, jobID) if !ok { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgJobNotFound))) return } + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + if job.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + // Respond immediately — job execution happens in background client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, @@ -216,14 +253,17 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req })) emitAudit(m.eventBus, client, "cron.run", "cron", jobID) + // Preserve tenant scope for async execution. + tenantID := store.TenantIDFromContext(ctx) go func() { - if _, _, err := m.service.RunJob(jobID, force); err != nil { + bgCtx := store.WithTenantID(context.Background(), tenantID) + if _, _, err := m.service.RunJob(bgCtx, jobID, force); err != nil { slog.Warn("cron.run background error", "jobId", jobID, "error", err) } }() } -func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *CronMethods) handleRuns(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { JobID string `json:"jobId"` ID string `json:"id"` @@ -239,7 +279,7 @@ func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req jobID = params.ID } - entries, total := m.service.GetRunLog(jobID, params.Limit, params.Offset) + entries, total := m.service.GetRunLog(ctx, jobID, params.Limit, params.Offset) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "entries": entries, "total": total, diff --git a/internal/gateway/methods/delegations.go b/internal/gateway/methods/delegations.go deleted file mode 100644 index 32110098..00000000 --- a/internal/gateway/methods/delegations.go +++ /dev/null @@ -1,137 +0,0 @@ -package methods - -import ( - "context" - "encoding/json" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/gateway" - "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -// DelegationsMethods handles delegations.* RPC methods. -type DelegationsMethods struct { - teamStore store.TeamStore -} - -func NewDelegationsMethods(teamStore store.TeamStore) *DelegationsMethods { - return &DelegationsMethods{teamStore: teamStore} -} - -func (m *DelegationsMethods) Register(router *gateway.MethodRouter) { - router.Register(protocol.MethodDelegationsList, m.handleList) - router.Register(protocol.MethodDelegationsGet, m.handleGet) -} - -func (m *DelegationsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { - locale := store.LocaleFromContext(ctx) - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable))) - return - } - - var params struct { - SourceAgentID string `json:"source_agent_id"` - TargetAgentID string `json:"target_agent_id"` - TeamID string `json:"team_id"` - UserID string `json:"user_id"` - Status string `json:"status"` - Limit int `json:"limit"` - Offset int `json:"offset"` - } - if req.Params != nil { - _ = json.Unmarshal(req.Params, ¶ms) - } - - opts := store.DelegationHistoryListOpts{ - UserID: params.UserID, - Status: params.Status, - Limit: params.Limit, - Offset: params.Offset, - } - - if params.SourceAgentID != "" { - if id, err := uuid.Parse(params.SourceAgentID); err == nil { - opts.SourceAgentID = &id - } - } - if params.TargetAgentID != "" { - if id, err := uuid.Parse(params.TargetAgentID); err == nil { - opts.TargetAgentID = &id - } - } - if params.TeamID != "" { - if id, err := uuid.Parse(params.TeamID); err == nil { - opts.TeamID = &id - } - } - - records, total, err := m.teamStore.ListDelegationHistory(ctx, opts) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - // Truncate results for WS transport - const maxResultRunes = 500 - for i := range records { - if records[i].Result != nil { - r := []rune(*records[i].Result) - if len(r) > maxResultRunes { - s := string(r[:maxResultRunes]) + "..." - records[i].Result = &s - } - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "records": records, - "total": total, - })) -} - -func (m *DelegationsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { - locale := store.LocaleFromContext(ctx) - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable))) - return - } - - var params struct { - ID string `json:"id"` - } - if req.Params != nil { - _ = json.Unmarshal(req.Params, ¶ms) - } - if params.ID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id"))) - return - } - - id, err := uuid.Parse(params.ID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "id"))) - return - } - - record, err := m.teamStore.GetDelegationHistory(ctx, id) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - // Truncate result for WS transport - const maxResultRunes = 8000 - if record.Result != nil { - r := []rune(*record.Result) - if len(r) > maxResultRunes { - s := string(r[:maxResultRunes]) + "..." - record.Result = &s - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, record)) -} diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go index 69641730..d0611828 100644 --- a/internal/gateway/methods/pairing.go +++ b/internal/gateway/methods/pairing.go @@ -70,7 +70,7 @@ func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Clie params.AccountID = "default" } - code, err := m.service.RequestPairing(params.SenderID, params.Channel, params.ChatID, params.AccountID, nil) + code, err := m.service.RequestPairing(ctx, params.SenderID, params.Channel, params.ChatID, params.AccountID, nil) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -99,7 +99,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie params.ApprovedBy = "operator" } - paired, err := m.service.ApprovePairing(params.Code, params.ApprovedBy) + paired, err := m.service.ApprovePairing(ctx, params.Code, params.ApprovedBy) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -135,7 +135,7 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, return } - if err := m.service.DenyPairing(params.Code); err != nil { + if err := m.service.DenyPairing(ctx, params.Code); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -150,9 +150,9 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, })) } -func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - pending := m.service.ListPending() - paired := m.service.ListPaired() +func (m *PairingMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + pending := m.service.ListPending(ctx) + paired := m.service.ListPaired(ctx) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "pending": pending, @@ -175,7 +175,7 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien return } - if err := m.service.RevokePairing(params.SenderID, params.Channel); err != nil { + if err := m.service.RevokePairing(ctx, params.SenderID, params.Channel); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -222,7 +222,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client return } - paired, pairErr := m.service.IsPaired(params.SenderID, "browser") + paired, pairErr := m.service.IsPaired(ctx, params.SenderID, "browser") if pairErr != nil { slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr) } @@ -234,7 +234,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client } // Check if the pairing request still exists (not expired) - pending := m.service.ListPending() + pending := m.service.ListPending(ctx) for _, p := range pending { if p.SenderID == params.SenderID && p.Channel == "browser" { client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ diff --git a/internal/gateway/methods/sessions.go b/internal/gateway/methods/sessions.go index 11f6290b..02124e43 100644 --- a/internal/gateway/methods/sessions.go +++ b/internal/gateway/methods/sessions.go @@ -5,7 +5,9 @@ import ( "encoding/json" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -15,10 +17,11 @@ import ( type SessionsMethods struct { sessions store.SessionStore eventBus bus.EventPublisher + cfg *config.Config } -func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher) *SessionsMethods { - return &SessionsMethods{sessions: sess, eventBus: eventBus} +func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher, cfg *config.Config) *SessionsMethods { + return &SessionsMethods{sessions: sess, eventBus: eventBus, cfg: cfg} } func (m *SessionsMethods) Register(router *gateway.MethodRouter) { @@ -36,7 +39,7 @@ type sessionsListParams struct { Offset int `json:"offset"` } -func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *SessionsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params sessionsListParams if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -47,18 +50,19 @@ func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client, } opts := store.SessionListOpts{ - AgentID: params.AgentID, - Channel: params.Channel, - Limit: params.Limit, - Offset: params.Offset, + AgentID: params.AgentID, + Channel: params.Channel, + Limit: params.Limit, + Offset: params.Offset, + TenantID: store.TenantIDFromContext(ctx), } - // Only filter by UserID when a channel filter is specified (e.g. chat sidebar sends channel="ws"). - // Sessions admin page omits channel → sees all sessions unfiltered. - if params.Channel != "" { + // Role-based filtering: admins/owners see all sessions; regular users see only their own. + // Tenant scope is always applied above — admin sees all sessions within the tenant. + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { opts.UserID = client.UserID() } - result := m.sessions.ListPagedRich(opts) + result := m.sessions.ListPagedRich(ctx, opts) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "sessions": result.Sessions, "total": result.Total, @@ -79,8 +83,26 @@ func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Cli return } - history := m.sessions.GetHistory(params.Key) - summary := m.sessions.GetSummary(params.Key) + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + + history := m.sessions.GetHistory(ctx, params.Key) + summary := m.sessions.GetSummary(ctx, params.Key) + + // Sign file URLs before delivery — sessions store clean paths. + for i := range history { + history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey()) + } + summary = httpapi.SignFileURLs(summary, httpapi.FileSigningKey()) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "key": params.Key, @@ -109,23 +131,35 @@ func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Clien return } + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + // Apply label patch if params.Label != nil { - m.sessions.SetLabel(params.Key, *params.Label) + m.sessions.SetLabel(ctx, params.Key, *params.Label) } // Apply model patch if params.Model != nil { - m.sessions.UpdateMetadata(params.Key, *params.Model, "", "") + m.sessions.UpdateMetadata(ctx, params.Key, *params.Model, "", "") } // Apply metadata patch if len(params.Metadata) > 0 { - m.sessions.SetSessionMetadata(params.Key, params.Metadata) + m.sessions.SetSessionMetadata(ctx, params.Key, params.Metadata) } // Save changes to DB - m.sessions.Save(params.Key) + m.sessions.Save(ctx, params.Key) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, @@ -142,7 +176,19 @@ func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Clie return } - if err := m.sessions.Delete(params.Key); err != nil { + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + + if err := m.sessions.Delete(ctx, params.Key); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return } @@ -161,7 +207,19 @@ func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Clien return } - m.sessions.Reset(params.Key) + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + + m.sessions.Reset(ctx, params.Key) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index a96b1062..5ff87570 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -33,8 +33,8 @@ func (m *SkillsMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodSkillsUpdate, m.handleUpdate) } -func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - allSkills := m.store.ListSkills() +func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + allSkills := m.store.ListSkills(ctx) result := make([]map[string]any, 0, len(allSkills)) for _, s := range allSkills { @@ -86,13 +86,13 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r return } - info, ok := m.store.GetSkill(params.Name) + info, ok := m.store.GetSkill(ctx, params.Name) if !ok { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name))) return } - content, _ := m.store.LoadSkill(params.Name) + content, _ := m.store.LoadSkill(ctx, params.Name) resp := map[string]any{ "name": info.Name, @@ -116,7 +116,7 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r // skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore). type skillUpdater interface { - UpdateSkill(id uuid.UUID, updates map[string]any) error + UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error } func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { @@ -153,7 +153,7 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client } else { // Look up by name — use GetSkill which returns path info, but we need DB ID // For PGSkillStore, the name is the slug - info, exists := m.store.GetSkill(params.Name) + info, exists := m.store.GetSkill(ctx, params.Name) if !exists { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name))) return @@ -172,17 +172,21 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client return } - // Ownership check: only skill owner or admin can update + // Ownership check: only skill owner or admin can update. + // Fail-closed: if store doesn't implement skillOwnerGetter, deny non-admin callers. if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { - if ownerGetter, ok := m.store.(skillOwnerGetter); ok { - if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update"))) - return - } + ownerGetter, ok := m.store.(skillOwnerGetter) + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update"))) + return + } + if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update"))) + return } } - if err := updater.UpdateSkill(skillID, params.Updates); err != nil { + if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return } diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index 53759aa5..a00c865b 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -10,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -22,7 +23,7 @@ type TeamsMethods struct { agentRouter *agent.Router // for cache invalidation msgBus *bus.MessageBus // for pub/sub cache invalidation eventBus bus.EventPublisher - dataDir string // workspace data directory for resolving file paths + dataDir string // workspace data directory for resolving file paths } func NewTeamsMethods(teamStore store.TeamStore, agentStore store.AgentStore, linkStore store.AgentLinkStore, agentRouter *agent.Router, msgBus *bus.MessageBus, eventBus bus.EventPublisher, dataDir string) *TeamsMethods { @@ -73,7 +74,14 @@ func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, r return } - teams, err := m.teamStore.ListTeams(ctx) + var teams []store.TeamData + var err error + if permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { + teams, err = m.teamStore.ListTeams(ctx) + } else { + callerID := store.UserIDFromContext(ctx) + teams, err = m.teamStore.ListUserTeams(ctx, callerID) + } if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return @@ -118,7 +126,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, } // Resolve lead agent - leadAgent, err := resolveAgentInfo(m.agentStore, params.Lead) + leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead agent: "+err.Error())) return @@ -134,7 +142,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, // Resolve member agents var memberAgents []*store.AgentData for _, memberKey := range params.Members { - ag, err := resolveAgentInfo(m.agentStore, memberKey) + ag, err := resolveAgentInfo(ctx, m.agentStore, memberKey) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "member agent "+memberKey+": "+err.Error())) return diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go index 1feed25d..58bcf229 100644 --- a/internal/gateway/methods/teams_crud.go +++ b/internal/gateway/methods/teams_crud.go @@ -10,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -50,6 +51,15 @@ func (m *TeamsMethods) handleGet(ctx context.Context, client *gateway.Client, re return } + // Non-admin callers must be a member of the team to view it. + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { + callerID := store.UserIDFromContext(ctx) + if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID))) + return + } + } + members, err := m.teamStore.ListMembers(ctx, teamID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) @@ -92,6 +102,15 @@ func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client, return } + // Non-admin callers must have team access to delete. + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { + callerID := store.UserIDFromContext(ctx) + if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID))) + return + } + } + // Fetch team and members before deleting for event + cache invalidation team, _ := m.teamStore.GetTeam(ctx, teamID) members, _ := m.teamStore.ListMembers(ctx, teamID) diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go index b58e320d..9dfe2116 100644 --- a/internal/gateway/methods/teams_members.go +++ b/internal/gateway/methods/teams_members.go @@ -53,7 +53,7 @@ func (m *TeamsMethods) handleAddMember(ctx context.Context, client *gateway.Clie } // Resolve agent - ag, err := resolveAgentInfo(m.agentStore, params.Agent) + ag, err := resolveAgentInfo(ctx, m.agentStore, params.Agent) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error())) return diff --git a/internal/gateway/methods/teams_tasks.go b/internal/gateway/methods/teams_tasks.go index 41c5956d..cfaa9078 100644 --- a/internal/gateway/methods/teams_tasks.go +++ b/internal/gateway/methods/teams_tasks.go @@ -12,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/gateway" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -104,6 +105,13 @@ func (m *TeamsMethods) handleTaskGet(ctx context.Context, client *gateway.Client events, _ := m.teamStore.ListTaskEvents(ctx, taskID) attachments, _ := m.teamStore.ListTaskAttachments(ctx, taskID) + // Sign download URLs at delivery time (same pattern as chat file URLs). + for i := range attachments { + dlPath := fmt.Sprintf("/v1/teams/%s/attachments/%s/download", teamID, attachments[i].ID) + ft := httpapi.SignFileToken(dlPath, httpapi.FileSigningKey(), httpapi.FileTokenTTL) + attachments[i].DownloadURL = dlPath + "?ft=" + ft + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "task": task, "comments": comments, diff --git a/internal/gateway/methods/teams_workspace.go b/internal/gateway/methods/teams_workspace.go index f9f389c4..52eb3804 100644 --- a/internal/gateway/methods/teams_workspace.go +++ b/internal/gateway/methods/teams_workspace.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strings" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -24,14 +26,54 @@ func (m *TeamsMethods) RegisterWorkspace(router *gateway.MethodRouter) { router.Register(protocol.MethodTeamsWorkspaceDelete, m.handleWorkspaceDelete) } -// teamWorkspaceDir returns the base directory for a team's workspace files. -// Pattern: {dataDir}/teams/{teamID}/ -// If chatID is provided, scopes to {dataDir}/teams/{teamID}/{chatID}/ -func teamWorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) string { - if chatID != "" { - return filepath.Join(dataDir, "teams", teamID.String(), chatID) +// resolveWorkspacePath resolves fileName within scopeDir and validates that the +// canonical path (after symlink resolution) stays inside the scope directory. +// Returns the resolved disk path or an error if the path escapes the boundary. +func resolveWorkspacePath(scopeDir, fileName string) (string, error) { + diskPath := filepath.Clean(filepath.Join(scopeDir, fileName)) + + // Resolve scope dir to canonical form. + scopeReal, err := filepath.EvalSymlinks(filepath.Clean(scopeDir)) + if err != nil { + scopeReal = filepath.Clean(scopeDir) } - return filepath.Join(dataDir, "teams", teamID.String()) + + // Resolve target path — follow symlinks to detect escapes. + diskReal, err := filepath.EvalSymlinks(diskPath) + if err != nil { + if !os.IsNotExist(err) { + slog.Warn("security.workspace_path_resolve_failed", "path", fileName, "error", err) + return "", fmt.Errorf("invalid file_name") + } + // File doesn't exist yet — validate parent directory. + parentReal, parentErr := filepath.EvalSymlinks(filepath.Dir(diskPath)) + if parentErr != nil { + return "", fmt.Errorf("invalid file_name") + } + diskReal = filepath.Join(parentReal, filepath.Base(diskPath)) + } + + // Boundary check: canonical path must be inside canonical scope. + if diskReal != scopeReal && !strings.HasPrefix(diskReal, scopeReal+string(filepath.Separator)) { + slog.Warn("security.workspace_path_escape", "path", fileName, "resolved", diskReal, "scope", scopeReal) + return "", fmt.Errorf("invalid file_name") + } + + return diskPath, nil +} + +// teamWorkspaceDir returns the base directory for a team's workspace files. +// Scoped to tenant via config.TenantTeamDir: {dataDir}/tenants/{slug}/teams/{teamID}/ +// Master tenant: {dataDir}/teams/{teamID}/ (backward compat). +// If chatID is provided, further scopes to .../{chatID}/ +func teamWorkspaceDir(ctx context.Context, dataDir string, teamID uuid.UUID, chatID string) string { + tid := store.TenantIDFromContext(ctx) + slug := store.TenantSlugFromContext(ctx) + base := config.TenantTeamDir(dataDir, tid, slug, teamID) + if chatID != "" { + return filepath.Join(base, chatID) + } + return base } // workspaceFileEntry is the response shape for workspace file listing. @@ -76,7 +118,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway. shared = tools.IsSharedWorkspace(team.Settings) } - baseDir := teamWorkspaceDir(m.dataDir, teamID, "") + baseDir := teamWorkspaceDir(ctx, m.dataDir, teamID, "") var files []workspaceFileEntry if shared || params.ChatID != "" { @@ -84,7 +126,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway. scopeDir := baseDir scopeChatID := "" if !shared && params.ChatID != "" { - scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID) + scopeDir = teamWorkspaceDir(ctx, m.dataDir, teamID, params.ChatID) scopeChatID = params.ChatID } files = walkDir(scopeDir, "", scopeChatID) @@ -207,11 +249,10 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway. return } - scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) - diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName)) - // Ensure resolved path stays within the workspace scope directory. - if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name")) + scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID) + diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName) + if pathErr != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error())) return } data, err := os.ReadFile(diskPath) @@ -283,11 +324,10 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa return } - scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) - diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName)) - // Ensure resolved path stays within the workspace scope directory. - if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name")) + scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID) + diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName) + if pathErr != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error())) return } if err := os.Remove(diskPath); err != nil { diff --git a/internal/gateway/methods/teams_workspace_test.go b/internal/gateway/methods/teams_workspace_test.go new file mode 100644 index 00000000..510e9d96 --- /dev/null +++ b/internal/gateway/methods/teams_workspace_test.go @@ -0,0 +1,124 @@ +package methods + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestResolveWorkspacePath_NormalFile(t *testing.T) { + scope := t.TempDir() + if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + diskPath, err := resolveWorkspacePath(scope, "file.txt") + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if filepath.Base(diskPath) != "file.txt" { + t.Fatalf("expected file.txt, got: %s", diskPath) + } +} + +func TestResolveWorkspacePath_NestedFile(t *testing.T) { + scope := t.TempDir() + sub := filepath.Join(scope, "sub") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + diskPath, err := resolveWorkspacePath(scope, "sub/nested.txt") + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if filepath.Base(diskPath) != "nested.txt" { + t.Fatalf("expected nested.txt, got: %s", diskPath) + } +} + +func TestResolveWorkspacePath_TraversalBlocked(t *testing.T) { + scope := t.TempDir() + // Even though teams_workspace.go checks for ".." in the RPC handler, + // resolveWorkspacePath should also catch it via boundary check. + _, err := resolveWorkspacePath(scope, "../../../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal, got nil") + } +} + +func TestResolveWorkspacePath_SymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require special privileges on Windows") + } + scope := t.TempDir() + outside := t.TempDir() + secret := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(secret, []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + + // Plant a symlink inside workspace pointing outside + link := filepath.Join(scope, "evil_link") + if err := os.Symlink(secret, link); err != nil { + t.Fatal(err) + } + + _, err := resolveWorkspacePath(scope, "evil_link") + if err == nil { + t.Fatal("expected error for symlink escaping workspace, got nil") + } +} + +func TestResolveWorkspacePath_DirSymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require special privileges on Windows") + } + scope := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + + // Plant a directory symlink inside workspace pointing outside + link := filepath.Join(scope, "evil_dir") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + + _, err := resolveWorkspacePath(scope, "evil_dir/secret.txt") + if err == nil { + t.Fatal("expected error for dir symlink escape, got nil") + } +} + +func TestResolveWorkspacePath_SymlinkInsideAllowed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require special privileges on Windows") + } + scope := t.TempDir() + target := filepath.Join(scope, "real.txt") + if err := os.WriteFile(target, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(scope, "good_link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + _, err := resolveWorkspacePath(scope, "good_link") + if err != nil { + t.Fatalf("expected success for symlink within workspace, got: %v", err) + } +} + +func TestResolveWorkspacePath_NonExistentFile(t *testing.T) { + scope := t.TempDir() + // Non-existent file in workspace should succeed (for new file creation) + _, err := resolveWorkspacePath(scope, "new_file.txt") + if err != nil { + t.Fatalf("expected success for non-existent file, got: %v", err) + } +} diff --git a/internal/gateway/methods/tenants.go b/internal/gateway/methods/tenants.go new file mode 100644 index 00000000..6108315d --- /dev/null +++ b/internal/gateway/methods/tenants.go @@ -0,0 +1,404 @@ +package methods + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "regexp" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +var slugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// TenantsMethods handles tenant management RPC methods. +type TenantsMethods struct { + tenantStore store.TenantStore + msgBus *bus.MessageBus + workspace string // base workspace directory for tenant dirs +} + +// NewTenantsMethods creates a new TenantsMethods handler. +func NewTenantsMethods(tenantStore store.TenantStore, msgBus *bus.MessageBus, workspace string) *TenantsMethods { + return &TenantsMethods{tenantStore: tenantStore, msgBus: msgBus, workspace: workspace} +} + +// Register registers tenant management RPC methods. +func (m *TenantsMethods) Register(router *gateway.MethodRouter) { + router.Register("tenants.list", m.handleList) + router.Register("tenants.get", m.handleGet) + router.Register("tenants.create", m.handleCreate) + router.Register("tenants.update", m.handleUpdate) + router.Register("tenants.users.list", m.handleUsersList) + router.Register("tenants.users.add", m.handleUsersAdd) + router.Register("tenants.users.remove", m.handleUsersRemove) + router.Register("tenants.mine", m.handleMine) +} + +func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list"))) + return + } + + tenants, err := m.tenantStore.ListTenants(ctx) + if err != nil { + slog.Error("tenants.list failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants"))) + return + } + if tenants == nil { + tenants = []store.TenantData{} + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": tenants})) +} + +func (m *TenantsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get"))) + return + } + + var params struct { + ID string `json:"id"` + } + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))) + return + } + } + + id, err := uuid.Parse(params.ID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant"))) + return + } + + tenant, err := m.tenantStore.GetTenant(ctx, id) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "tenant", params.ID))) + return + } + client.SendResponse(protocol.NewOKResponse(req.ID, tenant)) +} + +func (m *TenantsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create"))) + return + } + + var params struct { + Name string `json:"name"` + Slug string `json:"slug"` + Settings any `json:"settings"` + } + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))) + return + } + } + + if params.Name == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))) + return + } + if params.Slug == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "slug"))) + return + } + if !slugRe.MatchString(params.Slug) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "slug"))) + return + } + + tenant := &store.TenantData{ + ID: store.GenNewID(), + Name: params.Name, + Slug: params.Slug, + Status: store.TenantStatusActive, + } + + if err := m.tenantStore.CreateTenant(ctx, tenant); err != nil { + slog.Error("tenants.create failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error()))) + return + } + + // Create workspace directory for the tenant. + if m.workspace != "" { + tenantDir := filepath.Join(m.workspace, "tenants", tenant.Slug) + if err := os.MkdirAll(tenantDir, 0755); err != nil { + slog.Warn("tenants.create: failed to create workspace dir", "dir", tenantDir, "error", err) + } + } + + m.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String()) + m.emitCacheInvalidate(bus.CacheKindTenants, "") + client.SendResponse(protocol.NewOKResponse(req.ID, tenant)) +} + +func (m *TenantsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update"))) + return + } + + var params struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Settings map[string]any `json:"settings"` + } + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))) + return + } + } + + id, err := uuid.Parse(params.ID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant"))) + return + } + + updates := make(map[string]any) + if params.Name != "" { + updates["name"] = params.Name + } + if params.Status != "" { + updates["status"] = params.Status + } + if params.Settings != nil { + updates["settings"] = params.Settings + } + + if len(updates) == 0 { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates))) + return + } + + if err := m.tenantStore.UpdateTenant(ctx, id, updates); err != nil { + slog.Error("tenants.update failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error()))) + return + } + + m.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String()) + m.emitCacheInvalidate(bus.CacheKindTenants, "") + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"})) +} + +func (m *TenantsMethods) handleUsersList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list"))) + return + } + + var params struct { + TenantID string `json:"tenant_id"` + } + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))) + return + } + } + + tid, err := uuid.Parse(params.TenantID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))) + return + } + + users, err := m.tenantStore.ListUsers(ctx, tid) + if err != nil { + slog.Error("tenants.users.list failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenant users"))) + return + } + if users == nil { + users = []store.TenantUserData{} + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"users": users})) +} + +func (m *TenantsMethods) handleUsersAdd(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add"))) + return + } + + var params struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Role string `json:"role"` + } + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))) + return + } + } + + if params.UserID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id"))) + return + } + if params.Role == "" { + params.Role = store.TenantRoleMember + } + validRoles := map[string]bool{ + store.TenantRoleOwner: true, store.TenantRoleAdmin: true, + store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true, + } + if !validRoles[params.Role] { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRole))) + return + } + + tid, err := uuid.Parse(params.TenantID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))) + return + } + + if err := m.tenantStore.AddUser(ctx, tid, params.UserID, params.Role); err != nil { + slog.Error("tenants.users.add failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error()))) + return + } + + m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID) + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"})) +} + +func (m *TenantsMethods) handleUsersRemove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove"))) + return + } + + var params struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + } + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))) + return + } + } + + if params.UserID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id"))) + return + } + + tid, err := uuid.Parse(params.TenantID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))) + return + } + + if err := m.tenantStore.RemoveUser(ctx, tid, params.UserID); err != nil { + slog.Error("tenants.users.remove failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error()))) + return + } + + m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID) + + // Notify affected user's WS sessions to force logout + m.msgBus.Broadcast(bus.Event{ + Name: protocol.EventTenantAccessRevoked, + Payload: map[string]string{"user_id": params.UserID, "tenant_id": tid.String()}, + }) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"})) +} + +// handleMine returns the current user's tenant memberships. +// Unlike other tenant methods, this does NOT require cross-tenant access. +// Cross-tenant admins receive all tenants instead. +func (m *TenantsMethods) handleMine(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + + type tenantEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Role string `json:"role"` + Status string `json:"status"` + } + + // Cross-tenant admin: return all tenants with "owner" role + if client.IsCrossTenant() { + tenants, err := m.tenantStore.ListTenants(ctx) + if err != nil { + slog.Error("tenants.mine failed (cross-tenant)", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants"))) + return + } + entries := make([]tenantEntry, len(tenants)) + for i, t := range tenants { + entries[i] = tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: "owner", Status: t.Status} + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries})) + return + } + + // Regular user: return their tenant memberships enriched with name/slug + userID := client.UserID() + if userID == "" { + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": []tenantEntry{}})) + return + } + + memberships, err := m.tenantStore.ListUserTenants(ctx, userID) + if err != nil { + slog.Error("tenants.mine failed", "error", err, "user_id", userID) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants"))) + return + } + + entries := make([]tenantEntry, 0, len(memberships)) + for _, mem := range memberships { + t, err := m.tenantStore.GetTenant(ctx, mem.TenantID) + if err != nil || t == nil || t.Status != store.TenantStatusActive { + continue + } + entries = append(entries, tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: mem.Role, Status: t.Status}) + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries})) +} + +func (m *TenantsMethods) emitCacheInvalidate(kind, key string) { + if m.msgBus == nil { + return + } + m.msgBus.Broadcast(bus.Event{ + Name: protocol.EventCacheInvalidate, + Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key}, + }) +} diff --git a/internal/gateway/methods/usage.go b/internal/gateway/methods/usage.go index c2f705e2..e779a79c 100644 --- a/internal/gateway/methods/usage.go +++ b/internal/gateway/methods/usage.go @@ -37,7 +37,7 @@ func (m *UsageMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodUsageSummary, m.handleSummary) } -func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *UsageMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { AgentID string `json:"agentId"` Limit int `json:"limit"` @@ -50,12 +50,12 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req params.Limit = 20 } - sessions := m.sessions.List(params.AgentID) + sessions := m.sessions.List(ctx, params.AgentID) records := make([]UsageRecord, 0, len(sessions)) for _, s := range sessions { // Get full session data for token info - data := m.sessions.GetOrCreate(s.Key) + data := m.sessions.GetOrCreate(ctx, s.Key) if data.InputTokens == 0 && data.OutputTokens == 0 { continue } @@ -95,8 +95,8 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req })) } -func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - sessions := m.sessions.List("") // all agents +func (m *UsageMethods) handleSummary(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + sessions := m.sessions.List(ctx, "") // all agents type agentSummary struct { InputTokens int64 `json:"inputTokens"` @@ -109,7 +109,7 @@ func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client, var totalRecords int for _, s := range sessions { - data := m.sessions.GetOrCreate(s.Key) + data := m.sessions.GetOrCreate(ctx, s.Key) if data.InputTokens == 0 && data.OutputTokens == 0 { continue } diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 334b1d44..c0c06b28 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -7,6 +7,8 @@ import ( "log/slog" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/cache" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" @@ -19,8 +21,10 @@ type MethodHandler func(ctx context.Context, client *Client, req *protocol.Reque // MethodRouter maps method names to handlers. type MethodRouter struct { - handlers map[string]MethodHandler - server *Server + handlers map[string]MethodHandler + server *Server + tenantStore store.TenantStore // optional, for enriching connect response + permCache *cache.PermissionCache // optional, for caching tenant membership checks } func NewMethodRouter(server *Server) *MethodRouter { @@ -32,6 +36,12 @@ func NewMethodRouter(server *Server) *MethodRouter { return r } +// SetTenantStore sets the tenant store for enriching connect responses with tenant name/slug. +func (r *MethodRouter) SetTenantStore(ts store.TenantStore) { r.tenantStore = ts } + +// SetPermissionCache sets the permission cache for tenant membership checks. +func (r *MethodRouter) SetPermissionCache(pc *cache.PermissionCache) { r.permCache = pc } + // Register adds a method handler. func (r *MethodRouter) Register(method string, handler MethodHandler) { r.handlers[method] = handler @@ -67,8 +77,13 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol } } - // Inject locale into context for i18n support + // Inject locale + tenant into context. + // All connect paths now guarantee client.tenantID is set (cross-tenant defaults to MasterTenantID), + // so WithCrossTenant is no longer needed here. ctx = store.WithLocale(ctx, i18n.Normalize(client.locale)) + if client.TenantID() != uuid.Nil { + ctx = store.WithTenantID(ctx, client.TenantID()) + } slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID) handler(ctx, client, req) @@ -87,10 +102,13 @@ func (r *MethodRouter) registerDefaults() { func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *protocol.RequestFrame) { // Parse connect params var params struct { - Token string `json:"token"` - UserID string `json:"user_id"` - SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect - Locale string `json:"locale"` // user's preferred locale (en, vi, zh) + Token string `json:"token"` + UserID string `json:"user_id"` + SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect + Locale string `json:"locale"` // user's preferred locale (en, vi, zh) + TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant + TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug) + TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat) } if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -106,7 +124,39 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = permissions.RoleAdmin client.authenticated = true client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + + // Only owner IDs get cross-tenant (god-mode) access; + // other users with the gateway token still get admin role + // but are scoped to their tenant memberships. + isOwner := isOwnerID(params.UserID, r.server.cfg.Gateway.OwnerIDs) + client.crossTenant = isOwner + + if isOwner { + // Cross-tenant admin can narrow scope to a specific tenant + tenantScope := params.TenantID + if tenantScope == "" { + tenantScope = params.TenantScope // backward compat + } + r.applyTenantScope(ctx, client, tenantScope) + // Always ensure tenant is set — prevents unscoped operations + // that use MasterTenantID fallback inconsistently with scoped sessions. + if client.tenantID == uuid.Nil { + client.tenantID = store.MasterTenantID + } + } else { + // Non-owner with gateway token: resolve tenant via hint or membership + hint := params.TenantID + if hint == "" { + hint = params.TenantScope + } + tid, errCode := r.resolveTenantHint(ctx, hint, params.UserID) + if errCode != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked")) + return + } + client.tenantID = tid + } + r.sendConnectResponse(ctx, client, req.ID) return } @@ -120,8 +170,44 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = role client.scopes = scopes client.authenticated = true - client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + // If the key has a bound owner, force user_id to owner_id. + if keyData.OwnerID != "" { + if params.UserID != "" && params.UserID != keyData.OwnerID { + slog.Warn("security.ws_api_key_owner_override", + "param_user_id", params.UserID, + "owner_id", keyData.OwnerID, + ) + } + client.userID = keyData.OwnerID + } else { + client.userID = params.UserID + } + if keyData.TenantID == uuid.Nil { + client.crossTenant = true + // Cross-tenant API key can narrow scope to a specific tenant + apiKeyScope := params.TenantID + if apiKeyScope == "" { + apiKeyScope = params.TenantScope // backward compat + } + r.applyTenantScope(ctx, client, apiKeyScope) + if client.tenantID == uuid.Nil { + client.tenantID = store.MasterTenantID + } + slog.Debug("security.ws_connect_resolved", + "client", client.id, + "role", string(client.role), + "cross_tenant", client.crossTenant, + "tenant_id", client.tenantID.String(), + ) + } else { + client.tenantID = keyData.TenantID + slog.Debug("security.ws_connect_resolved", + "client", client.id, + "role", string(client.role), + "tenant_id", client.tenantID.String(), + ) + } + r.sendConnectResponse(ctx, client, req.ID) return } } @@ -131,7 +217,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = permissions.RoleOperator client.authenticated = true client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + client.tenantID = store.MasterTenantID + r.sendConnectResponse(ctx, client, req.ID) return } @@ -140,7 +227,7 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p // Path 3a: Reconnecting with a previously-paired sender_id if ps != nil && params.SenderID != "" { - paired, pairErr := ps.IsPaired(params.SenderID, "browser") + paired, pairErr := ps.IsPaired(ctx, params.SenderID, "browser") if pairErr != nil { slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr) @@ -156,15 +243,21 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.userID = params.UserID client.pairedSenderID = params.SenderID client.pairedChannel = "browser" - slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id) - r.sendConnectResponse(client, req.ID) + tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID) + if errCode != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked")) + return + } + client.tenantID = tid + slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id, "tenant_id", client.tenantID) + r.sendConnectResponse(ctx, client, req.ID) return } } // Path 3b: No token, no valid pairing → initiate browser pairing (if service available) if ps != nil && params.Token == "" { - code, err := ps.RequestPairing(client.id, "browser", "", "default", nil) + code, err := ps.RequestPairing(ctx, client.id, "browser", "", "default", nil) if err != nil { slog.Warn("browser pairing request failed", "error", err, "client", client.id) // Fall through to viewer role @@ -190,19 +283,134 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = permissions.RoleViewer client.authenticated = true client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID) + if errCode != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked")) + return + } + client.tenantID = tid + r.sendConnectResponse(ctx, client, req.ID) } -func (r *MethodRouter) sendConnectResponse(client *Client, reqID string) { - client.SendResponse(protocol.NewOKResponse(reqID, map[string]any{ - "protocol": protocol.ProtocolVersion, - "role": string(client.role), - "user_id": client.userID, +func (r *MethodRouter) sendConnectResponse(ctx context.Context, client *Client, reqID string) { + resp := map[string]any{ + "protocol": protocol.ProtocolVersion, + "role": string(client.role), + "user_id": client.userID, + "tenant_id": client.tenantID.String(), + "cross_tenant": client.crossTenant, "server": map[string]any{ "name": "goclaw", "version": "0.2.0", }, - })) + } + + // Enrich with tenant name/slug if tenant store available and tenant is set + if r.tenantStore != nil && client.tenantID != uuid.Nil { + if t, err := r.tenantStore.GetTenant(ctx, client.tenantID); err == nil && t != nil { + resp["tenant_name"] = t.Name + resp["tenant_slug"] = t.Slug + client.tenantName = t.Name + client.tenantSlug = t.Slug + } + } + + client.SendResponse(protocol.NewOKResponse(reqID, resp)) +} + +// isOwnerID checks if the given user ID is in the configured owner list. +// If no owner IDs configured, only "system" is treated as owner (fail-closed). +func isOwnerID(userID string, ownerIDs []string) bool { + if userID == "" { + return false + } + if len(ownerIDs) == 0 { + return userID == "system" + } + for _, id := range ownerIDs { + if id == userID { + return true + } + } + return false +} + +// resolveTenantHint resolves a tenant slug/UUID hint to a UUID with membership validation. +// Non-admin users must be a member of the tenant. +// Returns (uuid.Nil, ErrTenantAccessRevoked) when the user is not a member of the requested tenant. +// Returns (MasterTenantID, "") when no hint is provided. +func (r *MethodRouter) resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, string) { + if hint == "" || r.tenantStore == nil { + return store.MasterTenantID, "" + } + t, err := r.tenantStore.GetTenantBySlug(ctx, hint) + if err != nil || t == nil { + slog.Debug("tenant_hint not resolved, falling back to master", "hint", hint) + return store.MasterTenantID, "" + } + + // Validate membership: user must belong to the requested tenant. + // Deny tenant access for anonymous users (no userID) — fail-closed. + if userID == "" { + slog.Warn("security.tenant_hint_denied_anonymous", "hint", hint, "tenant_id", t.ID) + return uuid.Nil, protocol.ErrTenantAccessRevoked + } + role, err := r.getUserTenantRole(ctx, t.ID, userID) + if err != nil || role == "" { + slog.Warn("security.tenant_access_revoked", + "hint", hint, "user", userID, "tenant_id", t.ID, "error", err) + return uuid.Nil, protocol.ErrTenantAccessRevoked + } + return t.ID, "" +} + +// getUserTenantRole returns the user's role in a tenant, using permission cache if available. +func (r *MethodRouter) getUserTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) { + // Check cache first + if r.permCache != nil { + if role, ok := r.permCache.GetTenantRole(ctx, tenantID, userID); ok { + slog.Debug("perm_cache.tenant_role.hit", "tenant", tenantID, "user", userID, "role", role) + return role, nil + } + slog.Debug("perm_cache.tenant_role.miss", "tenant", tenantID, "user", userID) + } + + // Fallback to DB + role, err := r.tenantStore.GetUserRole(ctx, tenantID, userID) + if err != nil { + return "", err + } + + // Cache the result (including empty role = not a member) + if r.permCache != nil { + r.permCache.SetTenantRole(ctx, tenantID, userID, role) + } + return role, nil +} + +// applyTenantScope narrows a cross-tenant client's data scope to a specific tenant. +// Client stays crossTenant=true (retains admin privileges) but tenantID is set +// so the router injects WithTenantID instead of WithCrossTenant for data filtering. +// Accepts both UUID and slug values. +func (r *MethodRouter) applyTenantScope(ctx context.Context, client *Client, tenantVal string) { + if tenantVal == "" || r.tenantStore == nil { + return + } + // Try UUID first, then slug + var t *store.TenantData + var err error + if tid, parseErr := uuid.Parse(tenantVal); parseErr == nil { + t, err = r.tenantStore.GetTenant(ctx, tid) + } else { + t, err = r.tenantStore.GetTenantBySlug(ctx, tenantVal) + } + if err != nil || t == nil { + slog.Debug("tenant scope not resolved, keeping unscoped", "value", tenantVal) + return + } + client.tenantID = t.ID + // Keep crossTenant=true so client retains admin role + tenant admin access + slog.Info("tenant scope applied", "client", client.id, "tenant", t.Slug, "tenant_id", t.ID) } func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *protocol.RequestFrame) { @@ -266,7 +474,7 @@ func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *pr sessionCount := 0 if r.server.sessions != nil { - sessionCount = len(r.server.sessions.List("")) + sessionCount = len(r.server.sessions.List(ctx, "")) } // Agents are lazily resolved — router only has loaded agents. diff --git a/internal/gateway/server.go b/internal/gateway/server.go index fa05c7a7..9eeedce2 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -43,15 +43,14 @@ type Server struct { tracesHandler *httpapi.TracesHandler // LLM trace listing API wakeHandler *httpapi.WakeHandler // external wake/trigger API mcpHandler *httpapi.MCPHandler // MCP server management API - customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API providersHandler *httpapi.ProvidersHandler // provider CRUD API - delegationsHandler *httpapi.DelegationsHandler // delegation history API teamEventsHandler *httpapi.TeamEventsHandler // team event history API teamAttachmentsHandler *httpapi.TeamAttachmentsHandler // team attachment download API builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API + mcpUserCredsHandler *httpapi.MCPUserCredentialsHandler // MCP per-user credentials API packagesHandler *httpapi.PackagesHandler // runtime package management API memoryHandler *httpapi.MemoryHandler // memory management API kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API @@ -64,6 +63,7 @@ type Server struct { usageHandler *httpapi.UsageHandler // usage analytics API apiKeysHandler *httpapi.APIKeysHandler // API key management apiKeyStore store.APIKeyStore // for API key auth lookup + tenantsHandler *httpapi.TenantsHandler // tenant management API docsHandler *httpapi.DocsHandler // OpenAPI spec + Swagger UI agentStore store.AgentStore // for context injection in tools_invoke msgBus *bus.MessageBus // for MCP bridge media delivery @@ -207,10 +207,9 @@ func (s *Server) BuildMux() *http.ServeMux { if s.mcpHandler != nil { s.mcpHandler.RegisterRoutes(mux) } - - // Custom tool CRUD API - if s.customToolsHandler != nil { - s.customToolsHandler.RegisterRoutes(mux) + // MCP per-user credentials API + if s.mcpUserCredsHandler != nil { + s.mcpUserCredsHandler.RegisterRoutes(mux) } // Secure CLI credential CRUD API @@ -228,10 +227,6 @@ func (s *Server) BuildMux() *http.ServeMux { s.providersHandler.RegisterRoutes(mux) } - // Delegation history API - if s.delegationsHandler != nil { - s.delegationsHandler.RegisterRoutes(mux) - } // Team event history API if s.teamEventsHandler != nil { @@ -287,6 +282,11 @@ func (s *Server) BuildMux() *http.ServeMux { s.apiKeysHandler.RegisterRoutes(mux) } + // Tenant management API + if s.tenantsHandler != nil { + s.tenantsHandler.RegisterRoutes(mux) + } + if s.activityHandler != nil { s.activityHandler.RegisterRoutes(mux) } @@ -485,10 +485,8 @@ func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h } // SetMCPHandler sets the MCP server management handler. -func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h } - -// SetCustomToolsHandler sets the custom tool CRUD handler. -func (s *Server) SetCustomToolsHandler(h *httpapi.CustomToolsHandler) { s.customToolsHandler = h } +func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h } +func (s *Server) SetMCPUserCredentialsHandler(h *httpapi.MCPUserCredentialsHandler) { s.mcpUserCredsHandler = h } // SetChannelInstancesHandler sets the channel instance CRUD handler. func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) { @@ -498,9 +496,6 @@ func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) // SetProvidersHandler sets the provider CRUD handler. func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) { s.providersHandler = h } -// SetDelegationsHandler sets the delegation history handler. -func (s *Server) SetDelegationsHandler(h *httpapi.DelegationsHandler) { s.delegationsHandler = h } - // SetTeamEventsHandler sets the team event history handler. func (s *Server) SetTeamEventsHandler(h *httpapi.TeamEventsHandler) { s.teamEventsHandler = h } @@ -531,6 +526,9 @@ func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h } // SetAPIKeysHandler sets the API key management handler. func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) { s.apiKeysHandler = h } +// SetTenantsHandler sets the tenant management handler. +func (s *Server) SetTenantsHandler(h *httpapi.TenantsHandler) { s.tenantsHandler = h } + // SetAPIKeyStore sets the API key store for token-based auth lookup. func (s *Server) SetAPIKeyStore(st store.APIKeyStore) { s.apiKeyStore = st } @@ -623,12 +621,11 @@ func (s *Server) registerClient(c *Client) { defer s.mu.Unlock() s.clients[c.id] = c - // Subscribe to bus events for this client (skip internal cache events) + // Subscribe to bus events with per-user/team filtering. s.eventPub.Subscribe(c.id, func(event bus.Event) { - if strings.HasPrefix(event.Name, "cache.") { - return // internal event, don't forward to WS clients + if clientCanReceiveEvent(c, event) { + c.SendEvent(*protocol.NewEvent(event.Name, event.Payload)) } - c.SendEvent(*protocol.NewEvent(event.Name, event.Payload)) }) slog.Info("client connected", "id", c.id) diff --git a/internal/heartbeat/ticker.go b/internal/heartbeat/ticker.go index f79c65bd..19a7742b 100644 --- a/internal/heartbeat/ticker.go +++ b/internal/heartbeat/ticker.go @@ -152,10 +152,23 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) { start := time.Now() agentIDStr := hb.AgentID.String() - // Resolve agent key for events. + // Resolve agent to get tenant scope + display key. + // System-level lookup (cross-tenant) since ticker is a global scheduler. + sysCtx := store.WithCrossTenant(context.Background()) agentKey := agentIDStr - if ag, err := t.agents.GetByID(ctx, hb.AgentID); err == nil { - agentKey = ag.AgentKey + ag, agErr := t.agents.GetByID(sysCtx, hb.AgentID) + if agErr != nil { + slog.Warn("heartbeat.agent_not_found", "agent_id", agentIDStr, "error", agErr) + return + } + agentKey = ag.AgentKey + + // Inject agent's tenant into context so all store operations + // (context files, sessions, etc.) are tenant-scoped. + if ag.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, ag.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) } // [1] Active hours filter. @@ -336,7 +349,7 @@ func (t *Ticker) finishRun(ctx context.Context, hb store.AgentHeartbeat, session // Cleanup isolated session — data is already in heartbeat_run_logs. if hb.IsolatedSession && t.sessions != nil && sessionKey != "" { - if err := t.sessions.Delete(sessionKey); err != nil { + if err := t.sessions.Delete(ctx, sessionKey); err != nil { slog.Debug("heartbeat.session_cleanup_failed", "session_key", sessionKey, "error", err) } } diff --git a/internal/http/activity.go b/internal/http/activity.go index 7c46ba21..1853669f 100644 --- a/internal/http/activity.go +++ b/internal/http/activity.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -39,6 +40,13 @@ func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) { if v := r.URL.Query().Get("actor_id"); v != "" { opts.ActorID = v } + + // Non-admin callers may only see their own activity logs. + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + opts.ActorID = callerID + } if v := r.URL.Query().Get("action"); v != "" { opts.Action = v } diff --git a/internal/http/agents.go b/internal/http/agents.go index 549997d3..3c767972 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -120,6 +120,17 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { } req.OwnerID = userID + + // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant. + if store.IsCrossTenant(r.Context()) { + if req.TenantID == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tenant_id")}) + return + } + } else { + req.TenantID = store.TenantIDFromContext(r.Context()) + } + if req.AgentType == "" { req.AgentType = store.AgentTypeOpen } @@ -167,7 +178,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { // Start LLM summoning in background if applicable if req.Status == store.AgentStatusSummoning { - go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description) + go h.summoner.SummonAgent(req.ID, req.TenantID, req.Provider, req.Model, description) } emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String()) @@ -256,14 +267,13 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { // Cascade: if status changed, broadcast so channel instances and cron jobs react. if newStatus, ok := allowed["status"].(string); ok && newStatus != ag.Status { if h.msgBus != nil { - h.msgBus.Broadcast(bus.Event{ - Name: bus.EventAgentStatusChanged, - Payload: bus.AgentStatusChangedPayload{ + bus.BroadcastForTenant(h.msgBus, bus.EventAgentStatusChanged, + store.TenantIDFromContext(r.Context()), + bus.AgentStatusChangedPayload{ AgentID: id.String(), OldStatus: ag.Status, NewStatus: newStatus, - }, - }) + }) } } diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go index f9cd8755..9cdbaeaf 100644 --- a/internal/http/agents_sharing.go +++ b/internal/http/agents_sharing.go @@ -168,7 +168,7 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) return } - go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt) + go h.summoner.RegenerateAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, req.Prompt) emitAudit(h.msgBus, r, "agent.regenerated", "agent", id.String()) writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning}) @@ -214,7 +214,7 @@ func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) { return } - go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description) + go h.summoner.SummonAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, description) emitAudit(h.msgBus, r, "agent.resummoned", "agent", id.String()) writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning}) diff --git a/internal/http/api_key_cache.go b/internal/http/api_key_cache.go index 372f9872..4ca76552 100644 --- a/internal/http/api_key_cache.go +++ b/internal/http/api_key_cache.go @@ -2,6 +2,7 @@ package http import ( "context" + "log/slog" "sync" "time" @@ -49,9 +50,15 @@ func (c *apiKeyCache) get(hash string) (*store.APIKeyData, permissions.Role, boo // getOrFetch returns a cached entry or fetches from the store on cache miss. func (c *apiKeyCache) getOrFetch(ctx context.Context, hash string) (*store.APIKeyData, permissions.Role) { + hashPrefix := hash + if len(hashPrefix) > 8 { + hashPrefix = hashPrefix[:8] + } if key, role, ok := c.get(hash); ok { + slog.Debug("api_key_cache.hit", "hash_prefix", hashPrefix) return key, role } + slog.Debug("api_key_cache.miss", "hash_prefix", hashPrefix) // Cache miss — fetch from DB keyData, err := c.store.GetByHash(ctx, hash) diff --git a/internal/http/api_key_cache_test.go b/internal/http/api_key_cache_test.go index 7d3d643b..749a0fd2 100644 --- a/internal/http/api_key_cache_test.go +++ b/internal/http/api_key_cache_test.go @@ -41,10 +41,10 @@ func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error { return nil } -func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } -func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil } -func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil } -func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } +func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } +func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) { return nil, nil } +func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil } +func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil } func (m *mockAPIKeyStore) getCalls() int { m.mu.Lock() diff --git a/internal/http/api_keys.go b/internal/http/api_keys.go index 3f5c38e4..dbbed9ff 100644 --- a/internal/http/api_keys.go +++ b/internal/http/api_keys.go @@ -42,7 +42,8 @@ func (h *APIKeysHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc { func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - keys, err := h.apiKeys.List(r.Context()) + // HTTP API key list is admin-only (adminAuth middleware), so no owner filter needed. + keys, err := h.apiKeys.List(r.Context(), "") if err != nil { slog.Error("api_keys.list failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "API keys")}) @@ -61,6 +62,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Scopes []string `json:"scopes"` ExpiresIn *int `json:"expires_in"` // seconds; nil = never + TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key) } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) @@ -95,6 +97,22 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { return } + // Resolve tenant_id based on caller type. + var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB) + if store.IsCrossTenant(r.Context()) { + if input.TenantID != "" { + tid, err := uuid.Parse(input.TenantID) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant_id")}) + return + } + tenantID = tid + } + // else: uuid.Nil stays → system-level key + } else { + tenantID = store.TenantIDFromContext(r.Context()) + } + now := time.Now() key := &store.APIKeyData{ ID: store.GenNewID(), @@ -102,6 +120,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { Prefix: prefix, KeyHash: hash, Scopes: input.Scopes, + TenantID: tenantID, CreatedBy: extractUserID(r), CreatedAt: now, UpdatedAt: now, @@ -141,7 +160,8 @@ func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) { return } - if err := h.apiKeys.Revoke(r.Context(), id); err != nil { + // HTTP revoke is admin-only (adminAuth middleware), so no owner filter needed. + if err := h.apiKeys.Revoke(r.Context(), id, ""); err != nil { slog.Error("api_keys.revoke failed", "error", err, "id", idStr) writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "API key", idStr)}) return diff --git a/internal/http/audit.go b/internal/http/audit.go index 9de66e6e..19e4d848 100644 --- a/internal/http/audit.go +++ b/internal/http/audit.go @@ -29,6 +29,7 @@ func emitAudit(msgBus *bus.MessageBus, r *http.Request, action, entityType, enti EntityType: entityType, EntityID: entityID, IPAddress: r.RemoteAddr, + TenantID: store.TenantIDFromContext(r.Context()), }, }) } diff --git a/internal/http/auth.go b/internal/http/auth.go index 652fc62c..d8a27f20 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/crypto" "github.com/nextlevelbuilder/goclaw/internal/i18n" @@ -77,6 +78,8 @@ func extractAgentID(r *http.Request, model string) string { var pkgAPIKeyCache *apiKeyCache var pkgPairingStore store.PairingStore +var pkgTenantCache *tenantCache +var pkgOwnerIDs []string // InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation. // Must be called once during server startup before handling requests. @@ -97,6 +100,42 @@ func InitPairingAuth(ps store.PairingStore) { pkgPairingStore = ps } +// InitOwnerIDs sets the configured owner user IDs for HTTP auth. +// Only owners get cross-tenant access with gateway token; others are tenant-scoped. +func InitOwnerIDs(ids []string) { + pkgOwnerIDs = ids +} + +// isHTTPOwnerID checks if the user ID is a configured owner. +// If no owner IDs configured, only "system" is treated as owner (fail-closed). +func isHTTPOwnerID(userID string, ownerIDs []string) bool { + if userID == "" { + return false + } + if len(ownerIDs) == 0 { + return userID == "system" + } + for _, id := range ownerIDs { + if id == userID { + return true + } + } + return false +} + +// InitTenantStore sets the tenant cache for HTTP auth with TTL and pubsub invalidation. +// Allows gateway token requests to scope to a specific tenant via X-GoClaw-Tenant-Id header. +func InitTenantStore(ts store.TenantStore, mb *bus.MessageBus) { + pkgTenantCache = newTenantCache(ts, 5*time.Minute) + if mb != nil { + mb.Subscribe("http-tenant-cache", func(e bus.Event) { + if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok && p.Kind == bus.CacheKindTenants { + pkgTenantCache.invalidateAll() + } + }) + } +} + // ResolveAPIKey checks if the bearer token is a valid API key using the shared cache. // Returns the key data and derived role, or nil if not found/expired/revoked. func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) { @@ -111,6 +150,9 @@ func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permis type authResult struct { Role permissions.Role Authenticated bool + KeyData *store.APIKeyData // non-nil when authenticated via API key + TenantID uuid.UUID // resolved tenant; uuid.Nil for cross-tenant + CrossTenant bool // true for owner/system admin } // resolveAuth determines the caller's role from the request. @@ -123,18 +165,54 @@ func resolveAuth(r *http.Request, gatewayToken string) authResult { // Useful for handlers that also accept tokens from query params. func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult { // Gateway token → admin + // Only owner IDs get cross-tenant access; others are tenant-scoped. + // If no owner IDs configured, all gateway token users get cross-tenant (backward compat). if gatewayToken != "" && tokenMatch(bearer, gatewayToken) { - return authResult{Role: permissions.RoleAdmin, Authenticated: true} + userID := extractUserID(r) + isOwner := isHTTPOwnerID(userID, pkgOwnerIDs) + res := authResult{Role: permissions.RoleAdmin, Authenticated: true, CrossTenant: isOwner} + tenantVal := r.Header.Get("X-GoClaw-Tenant-Id") + if isOwner && tenantVal != "" && pkgTenantCache != nil { + // Cross-tenant admin can narrow scope via header + if tid, err := uuid.Parse(tenantVal); err == nil { + if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil { + res.TenantID = t.ID + } + } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil { + res.TenantID = t.ID + } + } else if !isOwner && pkgTenantCache != nil { + // Non-owner with gateway token: resolve tenant from header or fallback to master + if tenantVal != "" { + if tid, err := uuid.Parse(tenantVal); err == nil { + if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil { + res.TenantID = t.ID + } + } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil { + res.TenantID = t.ID + } + } + if res.TenantID == uuid.Nil { + res.TenantID = store.MasterTenantID + } + } + return res } // API key → role from scopes - if _, role := ResolveAPIKey(r.Context(), bearer); role != "" { - return authResult{Role: role, Authenticated: true} + if keyData, role := ResolveAPIKey(r.Context(), bearer); role != "" { + res := authResult{Role: role, Authenticated: true, KeyData: keyData} + if keyData.TenantID == uuid.Nil { + res.CrossTenant = true + } else { + res.TenantID = keyData.TenantID + } + return res } // Browser pairing → operator (via X-GoClaw-Sender-Id header) if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil { - paired, err := pkgPairingStore.IsPaired(senderID, "browser") + paired, err := pkgPairingStore.IsPaired(r.Context(), senderID, "browser") if err == nil && paired { - return authResult{Role: permissions.RoleOperator, Authenticated: true} + return authResult{Role: permissions.RoleOperator, Authenticated: true, TenantID: store.MasterTenantID} } if err != nil { slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err) @@ -144,7 +222,7 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult } // No auth configured → admin (no token = dev/single-user mode, full access) if gatewayToken == "" { - return authResult{Role: permissions.RoleAdmin, Authenticated: true} + return authResult{Role: permissions.RoleAdmin, Authenticated: true, TenantID: store.MasterTenantID} } return authResult{} } @@ -187,16 +265,60 @@ func requireAuth(token string, minRole permissions.Role, next http.HandlerFunc) } ctx := store.WithLocale(r.Context(), locale) - if userID := extractUserID(r); userID != "" { + userID := extractUserID(r) + // If the API key has a bound owner, force user_id to owner regardless of header. + if auth.KeyData != nil && auth.KeyData.OwnerID != "" { + if userID != "" && userID != auth.KeyData.OwnerID { + slog.Warn("security.api_key_owner_override", + "header_user_id", userID, + "owner_id", auth.KeyData.OwnerID, + ) + } + userID = auth.KeyData.OwnerID + } + if userID != "" { ctx = store.WithUserID(ctx, userID) } + if auth.CrossTenant && auth.TenantID != uuid.Nil { + // Cross-tenant admin with tenant scope: filter data by chosen tenant + ctx = store.WithTenantID(ctx, auth.TenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_scope", auth.TenantID.String(), + ) + } else if auth.CrossTenant { + // Auto-scope to MasterTenantID so all operations use a concrete tenant. + ctx = store.WithTenantID(ctx, store.MasterTenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "cross_tenant", true, + "auto_scope", store.MasterTenantID.String(), + ) + } else if auth.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, auth.TenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_id", auth.TenantID.String(), + ) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_id", store.MasterTenantID.String(), + ) + } next(w, r.WithContext(ctx)) } } // requireAuthBearer is like requireAuth but accepts a pre-extracted bearer token. // Used by handlers that accept tokens from query params (files, media). -func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) bool { +// Returns the authenticated request with user context applied (owner_id enforcement included). +func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) (*http.Request, bool) { locale := extractLocale(r) auth := resolveAuthBearer(r, token, bearer) @@ -204,7 +326,7 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w writeJSON(w, http.StatusUnauthorized, map[string]string{ "error": i18n.T(locale, i18n.MsgUnauthorized), }) - return false + return r, false } required := minRole @@ -216,9 +338,33 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w writeJSON(w, http.StatusForbidden, map[string]string{ "error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path), }) - return false + return r, false } - return true + + // Apply user context + owner_id enforcement (same as requireAuth). + ctx := store.WithLocale(r.Context(), locale) + userID := extractUserID(r) + if auth.KeyData != nil && auth.KeyData.OwnerID != "" { + if userID != "" && userID != auth.KeyData.OwnerID { + slog.Warn("security.api_key_owner_override", + "header_user_id", userID, + "owner_id", auth.KeyData.OwnerID, + "path", r.URL.Path, + ) + } + userID = auth.KeyData.OwnerID + } + if userID != "" { + ctx = store.WithUserID(ctx, userID) + } + if auth.CrossTenant { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } else if auth.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, auth.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + return r.WithContext(ctx), true } // extractLocale parses the Accept-Language header and returns a supported locale. diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go index 43e06ed9..f560bbd3 100644 --- a/internal/http/builtin_tools.go +++ b/internal/http/builtin_tools.go @@ -5,6 +5,8 @@ import ( "log/slog" "net/http" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -14,14 +16,15 @@ import ( // BuiltinToolsHandler handles built-in tool management endpoints. // Built-in tools are seeded at startup; only enabled and settings are editable. type BuiltinToolsHandler struct { - store store.BuiltinToolStore - token string - msgBus *bus.MessageBus + store store.BuiltinToolStore + tenantCfgStore store.BuiltinToolTenantConfigStore + token string + msgBus *bus.MessageBus } // NewBuiltinToolsHandler creates a handler for built-in tool management endpoints. -func NewBuiltinToolsHandler(s store.BuiltinToolStore, token string, msgBus *bus.MessageBus) *BuiltinToolsHandler { - return &BuiltinToolsHandler{store: s, token: token, msgBus: msgBus} +func NewBuiltinToolsHandler(s store.BuiltinToolStore, tenantCfgs store.BuiltinToolTenantConfigStore, token string, msgBus *bus.MessageBus) *BuiltinToolsHandler { + return &BuiltinToolsHandler{store: s, tenantCfgStore: tenantCfgs, token: token, msgBus: msgBus} } // RegisterRoutes registers all built-in tool routes on the given mux. @@ -29,6 +32,8 @@ func (h *BuiltinToolsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/tools/builtin", h.auth(h.handleList)) mux.HandleFunc("GET /v1/tools/builtin/{name}", h.auth(h.handleGet)) mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.auth(h.handleUpdate)) + mux.HandleFunc("PUT /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleSetTenantConfig)) + mux.HandleFunc("DELETE /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleDeleteTenantConfig)) } func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -53,6 +58,28 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")}) return } + + // Merge per-tenant overrides into response when tenant-scoped + tid := store.TenantIDFromContext(r.Context()) + if tid != uuid.Nil && h.tenantCfgStore != nil { + overrides, err := h.tenantCfgStore.ListAll(r.Context(), tid) + if err == nil && len(overrides) > 0 { + type toolWithTenant struct { + store.BuiltinToolDef + TenantEnabled *bool `json:"tenant_enabled"` + } + enriched := make([]toolWithTenant, len(result)) + for i, t := range result { + enriched[i] = toolWithTenant{BuiltinToolDef: t} + if enabled, ok := overrides[t.Name]; ok { + enriched[i].TenantEnabled = &enabled + } + } + writeJSON(w, http.StatusOK, map[string]any{"tools": enriched}) + return + } + } + writeJSON(w, http.StatusOK, map[string]any{"tools": result}) } @@ -111,3 +138,54 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques h.emitCacheInvalidate(name) writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } + +// handleSetTenantConfig sets a per-tenant override for a builtin tool. +func (h *BuiltinToolsHandler) handleSetTenantConfig(w http.ResponseWriter, r *http.Request) { + if h.tenantCfgStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"}) + return + } + name := r.PathValue("name") + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"}) + return + } + + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := h.tenantCfgStore.Set(r.Context(), tid, name, body.Enabled); err != nil { + slog.Warn("set tenant tool config failed", "tool", name, "tenant", tid, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// handleDeleteTenantConfig removes a per-tenant override (reverts to default). +func (h *BuiltinToolsHandler) handleDeleteTenantConfig(w http.ResponseWriter, r *http.Request) { + if h.tenantCfgStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"}) + return + } + name := r.PathValue("name") + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"}) + return + } + + if err := h.tenantCfgStore.Delete(r.Context(), tid, name); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index b670d292..cd55660e 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -21,13 +21,14 @@ type ChannelInstancesHandler struct { agentStore store.AgentStore configPermStore store.ConfigPermissionStore contactStore store.ContactStore + tenantStore store.TenantStore token string msgBus *bus.MessageBus } // NewChannelInstancesHandler creates a handler for channel instance management endpoints. -func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler { - return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, token: token, msgBus: msgBus} +func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, tenantStore store.TenantStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler { + return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, tenantStore: tenantStore, token: token, msgBus: msgBus} } // RegisterRoutes registers all channel instance routes on the given mux. @@ -42,6 +43,12 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { if h.contactStore != nil { mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts)) mux.HandleFunc("GET /v1/contacts/resolve", h.auth(h.handleResolveContacts)) + mux.HandleFunc("POST /v1/contacts/merge", h.auth(h.handleMergeContacts)) + mux.HandleFunc("POST /v1/contacts/unmerge", h.auth(h.handleUnmergeContacts)) + mux.HandleFunc("GET /v1/contacts/merged/{tenantUserId}", h.auth(h.handleListMergedContacts)) + } + if h.tenantStore != nil { + mux.HandleFunc("GET /v1/tenant-users", h.auth(h.handleListTenantUsers)) } // Group file writers (nested under channel instances) diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go index 5a523a17..85dda076 100644 --- a/internal/http/chat_completions.go +++ b/internal/http/chat_completions.go @@ -136,7 +136,7 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - loop, err := h.agents.Get(agentID) + loop, err := h.agents.Get(r.Context(), agentID) if err != nil { http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNotFound, "agent", agentID)), http.StatusNotFound) return @@ -205,7 +205,7 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http. Model: model, Choices: []chatChoice{{ Index: 0, - Message: &chatMessage{Role: "assistant", Content: result.Content}, + Message: &chatMessage{Role: "assistant", Content: SignFileURLs(result.Content, FileSigningKey())}, FinishReason: "stop", }}, } @@ -257,7 +257,7 @@ func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Req writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: "Error: " + err.Error()}, "stop") } else { // Send content chunk - writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: result.Content}, "stop") + writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: SignFileURLs(result.Content, FileSigningKey())}, "stop") } // Send [DONE] diff --git a/internal/http/contact_merge_handlers.go b/internal/http/contact_merge_handlers.go new file mode 100644 index 00000000..1e960b4b --- /dev/null +++ b/internal/http/contact_merge_handlers.go @@ -0,0 +1,198 @@ +package http + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleMergeContacts links selected contacts to a tenant_user identity. +// POST /v1/contacts/merge +func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + var body struct { + ContactIDs []uuid.UUID `json:"contact_ids"` + TenantUserID *uuid.UUID `json:"tenant_user_id"` + CreateUser *struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + } `json:"create_user"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if len(body.ContactIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgContactIDsRequired)}) + return + } + if len(body.ContactIDs) > 500 { + body.ContactIDs = body.ContactIDs[:500] + } + hasTU := body.TenantUserID != nil + hasCU := body.CreateUser != nil + if hasTU == hasCU { // must have exactly one + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMergeTargetRequired)}) + return + } + + var targetID uuid.UUID + + if hasTU { + // Link to existing tenant_user — verify same tenant. + tu, err := h.tenantStore.GetTenantUser(r.Context(), *body.TenantUserID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgTenantUserNotFound)}) + return + } + if tu.TenantID != tid { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgTenantMismatch)}) + return + } + targetID = tu.ID + } else { + // Create new tenant_user. + userID := body.CreateUser.UserID + displayName := body.CreateUser.DisplayName + if userID == "" { + // Fallback: derive from first contact's username. + userID = h.deriveUserIDFromContacts(r.Context(), body.ContactIDs) + } + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")}) + return + } + tu, err := h.tenantStore.CreateTenantUserReturning(r.Context(), tid, userID, displayName, store.TenantRoleMember) + if err != nil { + slog.Error("contacts.merge.create_user", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())}) + return + } + targetID = tu.ID + } + + if err := h.contactStore.MergeContacts(r.Context(), body.ContactIDs, targetID); err != nil { + slog.Error("contacts.merge", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "contacts", err.Error())}) + return + } + + emitAudit(h.msgBus, r, "contacts.merged", "tenant_user", targetID.String()) + writeJSON(w, http.StatusOK, map[string]any{ + "merged_id": targetID, + "merged_count": len(body.ContactIDs), + }) +} + +// handleUnmergeContacts removes merged_id from selected contacts. +// POST /v1/contacts/unmerge +func (h *ChannelInstancesHandler) handleUnmergeContacts(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + var body struct { + ContactIDs []uuid.UUID `json:"contact_ids"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + if len(body.ContactIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgContactIDsRequired)}) + return + } + if len(body.ContactIDs) > 500 { + body.ContactIDs = body.ContactIDs[:500] + } + + if err := h.contactStore.UnmergeContacts(r.Context(), body.ContactIDs); err != nil { + slog.Error("contacts.unmerge", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "contacts", err.Error())}) + return + } + + emitAudit(h.msgBus, r, "contacts.unmerged", "contacts", "") + writeJSON(w, http.StatusOK, map[string]any{"unmerged_count": len(body.ContactIDs)}) +} + +// handleListMergedContacts returns contacts linked to a tenant_user. +// GET /v1/contacts/merged/{tenantUserId} +func (h *ChannelInstancesHandler) handleListMergedContacts(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + mergedID, err := uuid.Parse(r.PathValue("tenantUserId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenantUserId")}) + return + } + + contacts, err := h.contactStore.GetContactsByMergedID(r.Context(), mergedID) + if err != nil { + slog.Error("contacts.merged.list", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")}) + return + } + if contacts == nil { + contacts = []store.ChannelContact{} + } + writeJSON(w, http.StatusOK, map[string]any{"contacts": contacts}) +} + +// handleListTenantUsers returns users for the current tenant (for merge dialog dropdown). +// GET /v1/tenant-users +func (h *ChannelInstancesHandler) handleListTenantUsers(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + users, err := h.tenantStore.ListUsers(r.Context(), tid) + if err != nil { + slog.Error("tenant_users.list", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")}) + return + } + if users == nil { + users = []store.TenantUserData{} + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +// deriveUserIDFromContacts returns the first contact's username or sender_id as fallback user_id. +func (h *ChannelInstancesHandler) deriveUserIDFromContacts(ctx context.Context, contactIDs []uuid.UUID) string { + if len(contactIDs) == 0 { + return "" + } + c, err := h.contactStore.GetContactByID(ctx, contactIDs[0]) + if err != nil { + return "" + } + if c.Username != nil && *c.Username != "" { + return *c.Username + } + return c.SenderID +} diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go deleted file mode 100644 index 5b574cad..00000000 --- a/internal/http/custom_tools.go +++ /dev/null @@ -1,214 +0,0 @@ -package http - -import ( - "encoding/json" - "log/slog" - "net/http" - "strconv" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/tools" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -// CustomToolsHandler handles custom tool CRUD endpoints. -type CustomToolsHandler struct { - store store.CustomToolStore - token string - msgBus *bus.MessageBus - toolsReg *tools.Registry // for name collision checking on create -} - -// NewCustomToolsHandler creates a handler for custom tool management endpoints. -func NewCustomToolsHandler(s store.CustomToolStore, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry) *CustomToolsHandler { - return &CustomToolsHandler{store: s, token: token, msgBus: msgBus, toolsReg: toolsReg} -} - -// RegisterRoutes registers all custom tool routes on the given mux. -func (h *CustomToolsHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /v1/tools/custom", h.auth(h.handleList)) - mux.HandleFunc("POST /v1/tools/custom", h.auth(h.handleCreate)) - mux.HandleFunc("GET /v1/tools/custom/{id}", h.auth(h.handleGet)) - mux.HandleFunc("PUT /v1/tools/custom/{id}", h.auth(h.handleUpdate)) - mux.HandleFunc("DELETE /v1/tools/custom/{id}", h.auth(h.handleDelete)) -} - -func (h *CustomToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc { - return requireAuth(h.token, "", next) -} - -func (h *CustomToolsHandler) emitCacheInvalidate(key string) { - if h.msgBus == nil { - return - } - h.msgBus.Broadcast(bus.Event{ - Name: protocol.EventCacheInvalidate, - Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindCustomTools, Key: key}, - }) -} - -func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - opts := store.CustomToolListOpts{ - Limit: 50, - Offset: 0, - } - - if v := r.URL.Query().Get("agent_id"); v != "" { - id, err := uuid.Parse(v) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) - return - } - opts.AgentID = &id - } - if v := r.URL.Query().Get("search"); v != "" { - opts.Search = v - } - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { - opts.Limit = n - } - } - if v := r.URL.Query().Get("offset"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - opts.Offset = n - } - } - - result, err := h.store.ListPaged(r.Context(), opts) - if err != nil { - slog.Error("custom_tools.list", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")}) - return - } - - total, _ := h.store.CountTools(r.Context(), opts) - - writeJSON(w, http.StatusOK, map[string]any{ - "tools": result, - "total": total, - "limit": opts.Limit, - "offset": opts.Offset, - }) -} - -func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - var def store.CustomToolDef - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&def); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) - return - } - - if def.Name == "" || def.Command == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and command")}) - return - } - if !isValidSlug(def.Name) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")}) - return - } - - // Check name collision with built-in/MCP tools - if h.toolsReg != nil { - if _, exists := h.toolsReg.Get(def.Name); exists { - writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "tool name", def.Name)}) - return - } - } - - userID := store.UserIDFromContext(r.Context()) - if userID != "" { - def.CreatedBy = userID - } - - if def.TimeoutSeconds <= 0 { - def.TimeoutSeconds = 60 - } - - if err := h.store.Create(r.Context(), &def); err != nil { - slog.Error("custom_tools.create", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - emitAudit(h.msgBus, r, "custom_tool.created", "custom_tool", def.ID.String()) - h.emitCacheInvalidate(def.ID.String()) - writeJSON(w, http.StatusCreated, def) -} - -func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")}) - return - } - - def, err := h.store.Get(r.Context(), id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", id.String())}) - return - } - - writeJSON(w, http.StatusOK, def) -} - -func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")}) - return - } - - var updates map[string]any - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) - return - } - - if name, ok := updates["name"]; ok { - if s, _ := name.(string); !isValidSlug(s) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")}) - return - } - } - - // Allowlist: only permit known custom tool columns. - updates = filterAllowedKeys(updates, customToolAllowedFields) - - if err := h.store.Update(r.Context(), id, updates); err != nil { - slog.Error("custom_tools.update", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - emitAudit(h.msgBus, r, "custom_tool.updated", "custom_tool", id.String()) - h.emitCacheInvalidate(id.String()) - writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) -} - -func (h *CustomToolsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")}) - return - } - - if err := h.store.Delete(r.Context(), id); err != nil { - slog.Error("custom_tools.delete", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - emitAudit(h.msgBus, r, "custom_tool.deleted", "custom_tool", id.String()) - h.emitCacheInvalidate(id.String()) - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) -} diff --git a/internal/http/delegations.go b/internal/http/delegations.go deleted file mode 100644 index d72b3870..00000000 --- a/internal/http/delegations.go +++ /dev/null @@ -1,98 +0,0 @@ -package http - -import ( - "net/http" - "strconv" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// DelegationsHandler handles delegation history HTTP endpoints. -type DelegationsHandler struct { - teamStore store.TeamStore - token string -} - -func NewDelegationsHandler(teamStore store.TeamStore, token string) *DelegationsHandler { - return &DelegationsHandler{teamStore: teamStore, token: token} -} - -func (h *DelegationsHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /v1/delegations", h.authMiddleware(h.handleList)) - mux.HandleFunc("GET /v1/delegations/{id}", h.authMiddleware(h.handleGet)) -} - -func (h *DelegationsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { - return requireAuth(h.token, "", next) -} - -func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request) { - opts := store.DelegationHistoryListOpts{ - Limit: 50, - Offset: 0, - } - - if v := r.URL.Query().Get("source_agent_id"); v != "" { - if id, err := uuid.Parse(v); err == nil { - opts.SourceAgentID = &id - } - } - if v := r.URL.Query().Get("target_agent_id"); v != "" { - if id, err := uuid.Parse(v); err == nil { - opts.TargetAgentID = &id - } - } - if v := r.URL.Query().Get("team_id"); v != "" { - if id, err := uuid.Parse(v); err == nil { - opts.TeamID = &id - } - } - if v := r.URL.Query().Get("user_id"); v != "" { - opts.UserID = v - } - if v := r.URL.Query().Get("status"); v != "" { - opts.Status = v - } - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { - opts.Limit = n - } - } - if v := r.URL.Query().Get("offset"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - opts.Offset = n - } - } - - records, total, err := h.teamStore.ListDelegationHistory(r.Context(), opts) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "records": records, - "total": total, - }) -} - -func (h *DelegationsHandler) handleGet(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - idStr := r.PathValue("id") - id, err := uuid.Parse(idStr) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "delegation")}) - return - } - - record, err := h.teamStore.GetDelegationHistory(r.Context(), id) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, record) -} diff --git a/internal/http/file_token.go b/internal/http/file_token.go new file mode 100644 index 00000000..45770277 --- /dev/null +++ b/internal/http/file_token.go @@ -0,0 +1,89 @@ +package http + +import ( + "crypto/hmac" + crypto_rand "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// FileTokenTTL is the default TTL for signed file tokens. +const FileTokenTTL = 5 * time.Minute + +var ( + fileSigningKey string + fileSigningKeyOnce sync.Once +) + +// FileSigningKey returns a random 32-byte key for HMAC file token signing. +// Generated once at startup, lives in memory only. Tokens expire on restart +// which is acceptable for the short TTL — clients re-fetch signed URLs on reconnect. +func FileSigningKey() string { + fileSigningKeyOnce.Do(func() { + b := make([]byte, 32) + crypto_rand.Read(b) + fileSigningKey = base64.RawURLEncoding.EncodeToString(b) + }) + return fileSigningKey +} + +// SignFileToken creates a short-lived HMAC token for file access. +// Token format: {base64url_hmac_16bytes}.{unix_expiry} (~40 chars). +// The path is bound into the signature so tokens can't be reused for other files. +func SignFileToken(path, secret string, ttl time.Duration) string { + expiry := time.Now().Add(ttl).Unix() + sig := fileTokenHMAC(path, secret, expiry) + return fmt.Sprintf("%s.%d", sig, expiry) +} + +// VerifyFileToken validates a signed file token against a path and secret. +// Returns true if the HMAC matches and the token has not expired. +func VerifyFileToken(token, path, secret string) bool { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return false + } + expiry, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || time.Now().Unix() > expiry { + return false + } + expected := fileTokenHMAC(path, secret, expiry) + return hmac.Equal([]byte(parts[0]), []byte(expected)) +} + +// fileTokenHMAC computes the HMAC signature component. +func fileTokenHMAC(path, secret string, expiry int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(fmt.Sprintf("%s:%d", path, expiry))) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) +} + +// fileURLRe matches /v1/files/... and /v1/media/... URLs in markdown and plain text. +// Captures the full URL path (stops at whitespace, closing paren, quote, or angle bracket). +var fileURLRe = regexp.MustCompile(`(/v1/(?:files|media)/[^\s)"'<>]+)`) + +// SignFileURLs finds all /v1/files/ and /v1/media/ URLs in content and appends +// a signed ?ft= token. Used at delivery time (WS events, HTTP responses) to avoid +// persisting tokens in session messages. Skips URLs that already have ?ft=. +func SignFileURLs(content, secret string) string { + if secret == "" || !strings.Contains(content, "/v1/") { + return content + } + return fileURLRe.ReplaceAllStringFunc(content, func(url string) string { + if strings.Contains(url, "ft=") { + return url // already signed + } + ft := SignFileToken(url, secret, FileTokenTTL) + sep := "?" + if strings.Contains(url, "?") { + sep = "&" + } + return url + sep + "ft=" + ft + }) +} diff --git a/internal/http/files.go b/internal/http/files.go index 3962b081..49e1130c 100644 --- a/internal/http/files.go +++ b/internal/http/files.go @@ -8,7 +8,9 @@ import ( "path/filepath" "strings" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // FilesHandler serves files over HTTP with Bearer token auth. @@ -18,12 +20,14 @@ import ( type FilesHandler struct { token string workspace string // workspace root for fallback file search + dataDir string // data directory root for tenant path validation } // NewFilesHandler creates a handler that serves files by absolute path. // workspace is the root directory used for fallback generated file search. -func NewFilesHandler(token, workspace string) *FilesHandler { - return &FilesHandler{token: token, workspace: workspace} +// dataDir is used for tenant path validation (files must be within tenant's dirs). +func NewFilesHandler(token, workspace, dataDir string) *FilesHandler { + return &FilesHandler{token: token, workspace: workspace, dataDir: dataDir} } // RegisterRoutes registers the file serving route. @@ -33,15 +37,23 @@ func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) { func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Accept token via Bearer header or ?token= query param (for ). - provided := extractBearerToken(r) - if provided == "" { - provided = r.URL.Query().Get("token") - } - if !requireAuthBearer(h.token, "", provided, w, r) { + // Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token. + if ft := r.URL.Query().Get("ft"); ft != "" { + path := "/v1/files/" + r.PathValue("path") + if VerifyFileToken(ft, path, FileSigningKey()) { + next(w, r) + return + } + http.Error(w, "invalid or expired file token", http.StatusUnauthorized) return } - next(w, r) + // Priority 2: Bearer header (API clients only). + provided := extractBearerToken(r) + authedReq, ok := requireAuthBearer(h.token, "", provided, w, r) + if !ok { + return + } + next(w, authedReq) } } @@ -81,11 +93,27 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) { } } + // Tenant isolation: validate absolute path is within tenant's allowed directories. + // Skip for HMAC file tokens — the path is cryptographically bound in the signature, + // so it cannot be tampered with. File tokens are generated server-side with the correct path. + if r.URL.Query().Get("ft") == "" { + tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())) + tenantWs := h.tenantWorkspace(r) + if !strings.HasPrefix(absPath, tenantData+string(filepath.Separator)) && + !strings.HasPrefix(absPath, tenantWs+string(filepath.Separator)) && + absPath != tenantData && absPath != tenantWs { + http.NotFound(w, r) + return + } + } + info, err := os.Stat(absPath) if err != nil || info.IsDir() { // Fallback: search workspace for file by basename (handles LLM-hallucinated paths). // Generated filenames (goclaw_gen_*) include nanosecond timestamps and are globally unique. - if resolved := h.findInWorkspace(filepath.Base(absPath)); resolved != "" { + // Workspace scoped to tenant to prevent cross-tenant file discovery. + ws := h.tenantWorkspace(r) + if resolved := h.findInWorkspace(ws, filepath.Base(absPath)); resolved != "" { absPath = resolved info, _ = os.Stat(absPath) } else { @@ -104,22 +132,29 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, absPath) } +// tenantWorkspace resolves the workspace scoped to the requesting tenant. +func (h *FilesHandler) tenantWorkspace(r *http.Request) string { + tid := store.TenantIDFromContext(r.Context()) + slug := store.TenantSlugFromContext(r.Context()) + return config.TenantWorkspace(h.workspace, tid, slug) +} + // findInWorkspace searches the workspace directory tree for a file by basename. // Returns the absolute path if found, empty string otherwise. // Searches team directories including generated/ and system/ subdirs. -func (h *FilesHandler) findInWorkspace(basename string) string { - if h.workspace == "" || basename == "" { +func (h *FilesHandler) findInWorkspace(workspace, basename string) string { + if workspace == "" || basename == "" { return "" } var found string - _ = filepath.WalkDir(h.workspace, func(path string, d os.DirEntry, err error) error { + _ = filepath.WalkDir(workspace, func(path string, d os.DirEntry, err error) error { if err != nil { return filepath.SkipDir } if d.IsDir() { name := d.Name() // Allow workspace root + known directory structures - if name == "teams" || name == "generated" || name == "system" || path == h.workspace { + if name == "teams" || name == "generated" || name == "system" || name == ".uploads" || path == workspace { return nil } // Allow date directories (e.g. 2026-03-20) diff --git a/internal/http/knowledge_graph.go b/internal/http/knowledge_graph.go index 1606c04f..1cd73a29 100644 --- a/internal/http/knowledge_graph.go +++ b/internal/http/knowledge_graph.go @@ -1,6 +1,7 @@ package http import ( + "context" "net/http" kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph" @@ -21,11 +22,11 @@ func NewKnowledgeGraphHandler(s store.KnowledgeGraphStore, providerReg *provider } // NewExtractor creates an Extractor from the given provider name and model. -func (h *KnowledgeGraphHandler) NewExtractor(providerName, model string, minConfidence float64) *kg.Extractor { +func (h *KnowledgeGraphHandler) NewExtractor(ctx context.Context, providerName, model string, minConfidence float64) *kg.Extractor { if h.providerReg == nil || providerName == "" || model == "" { return nil } - p, err := h.providerReg.Get(providerName) + p, err := h.providerReg.Get(ctx, providerName) if err != nil { return nil } diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go index 63472969..75e2a241 100644 --- a/internal/http/knowledge_graph_handlers.go +++ b/internal/http/knowledge_graph_handlers.go @@ -179,7 +179,7 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req return } - extractor := h.NewExtractor(body.Provider, body.Model, body.MinConf) + extractor := h.NewExtractor(r.Context(), body.Provider, body.Model, body.MinConf) if extractor == nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidProviderOrModel)}) return diff --git a/internal/http/mcp.go b/internal/http/mcp.go index 8830afe6..85ad6f34 100644 --- a/internal/http/mcp.go +++ b/internal/http/mcp.go @@ -18,12 +18,18 @@ type MCPToolLister interface { ServerToolNames(serverName string) []string } +// MCPPoolEvictor evicts pooled connections for a tenant+server (called on credential rotation). +type MCPPoolEvictor interface { + Evict(tenantID uuid.UUID, serverName string) +} + // MCPHandler handles MCP server management HTTP endpoints. type MCPHandler struct { - store store.MCPServerStore - token string - msgBus *bus.MessageBus - mgr MCPToolLister // optional, nil when Manager not available + store store.MCPServerStore + token string + msgBus *bus.MessageBus + mgr MCPToolLister // optional, nil when Manager not available + poolEvictor MCPPoolEvictor // optional, nil when pool not available } // NewMCPHandler creates a handler for MCP server management endpoints. @@ -31,6 +37,9 @@ func NewMCPHandler(s store.MCPServerStore, token string, msgBus *bus.MessageBus, return &MCPHandler{store: s, token: token, msgBus: msgBus, mgr: mgr} } +// SetPoolEvictor sets the pool evictor for credential rotation handling. +func (h *MCPHandler) SetPoolEvictor(e MCPPoolEvictor) { h.poolEvictor = e } + func (h *MCPHandler) emitCacheInvalidate() { if h.msgBus == nil { return @@ -177,12 +186,29 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request) // Allowlist: only permit known MCP server columns. updates = filterAllowedKeys(updates, mcpServerAllowedFields) + // Read server name before update for pool eviction + var serverName string + if srv, _ := h.store.GetServer(r.Context(), id); srv != nil { + serverName = srv.Name + } + if err := h.store.UpdateServer(r.Context(), id, updates); err != nil { slog.Error("mcp.update_server", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } + // Evict pool connections when credentials change (force reconnect with new creds) + if h.poolEvictor != nil && serverName != "" { + _, hasKey := updates["api_key"] + _, hasHeaders := updates["headers"] + _, hasEnv := updates["env"] + if hasKey || hasHeaders || hasEnv { + tid := store.TenantIDFromContext(r.Context()) + h.poolEvictor.Evict(tid, serverName) + } + } + h.emitCacheInvalidate() emitAudit(h.msgBus, r, "mcp_server.updated", "mcp_server", id.String()) writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) diff --git a/internal/http/mcp_user_credentials.go b/internal/http/mcp_user_credentials.go new file mode 100644 index 00000000..3e2ddd1d --- /dev/null +++ b/internal/http/mcp_user_credentials.go @@ -0,0 +1,108 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// MCPUserCredentialsHandler handles per-user MCP credential endpoints. +type MCPUserCredentialsHandler struct { + store store.MCPServerStore + token string +} + +// NewMCPUserCredentialsHandler creates a handler for MCP user credential endpoints. +func NewMCPUserCredentialsHandler(s store.MCPServerStore, token string) *MCPUserCredentialsHandler { + return &MCPUserCredentialsHandler{store: s, token: token} +} + +// RegisterRoutes registers MCP user credential routes. +func (h *MCPUserCredentialsHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("PUT /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleSet)) + mux.HandleFunc("GET /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleGet)) + mux.HandleFunc("DELETE /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleDelete)) +} + +func (h *MCPUserCredentialsHandler) auth(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, "", next) +} + +func (h *MCPUserCredentialsHandler) handleSet(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) + return + } + + var creds store.MCPUserCredentials + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&creds); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := h.store.SetUserCredentials(r.Context(), serverID, userID, creds); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +func (h *MCPUserCredentialsHandler) handleGet(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) + return + } + + creds, err := h.store.GetUserCredentials(r.Context(), serverID, userID) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"has_credentials": false}) + return + } + + // Return masked status (never expose actual credentials) + writeJSON(w, http.StatusOK, map[string]any{ + "has_credentials": true, + "has_api_key": creds.APIKey != "", + "has_headers": len(creds.Headers) > 0, + "has_env": len(creds.Env) > 0, + }) +} + +func (h *MCPUserCredentialsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) + return + } + + if err := h.store.DeleteUserCredentials(r.Context(), serverID, userID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/http/media_serve.go b/internal/http/media_serve.go index 52c71c8a..7e997f08 100644 --- a/internal/http/media_serve.go +++ b/internal/http/media_serve.go @@ -29,15 +29,23 @@ func (h *MediaServeHandler) RegisterRoutes(mux *http.ServeMux) { func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Accept token via Bearer header or ?token= query param (for ). - provided := extractBearerToken(r) - if provided == "" { - provided = r.URL.Query().Get("token") - } - if !requireAuthBearer(h.token, "", provided, w, r) { + // Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token. + if ft := r.URL.Query().Get("ft"); ft != "" { + mediaID := r.PathValue("id") + if VerifyFileToken(ft, "/v1/media/"+mediaID, FileSigningKey()) { + next(w, r) + return + } + http.Error(w, "invalid or expired file token", http.StatusUnauthorized) return } - next(w, r) + // Priority 2: Bearer header (API clients only). + provided := extractBearerToken(r) + authedReq, ok := requireAuthBearer(h.token, "", provided, w, r) + if !ok { + return + } + next(w, authedReq) } } diff --git a/internal/http/oauth.go b/internal/http/oauth.go index 0d789089..82350614 100644 --- a/internal/http/oauth.go +++ b/internal/http/oauth.go @@ -52,12 +52,16 @@ func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } -func (h *OAuthHandler) newTokenSource() *oauth.DBTokenSource { - return oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) +func (h *OAuthHandler) newTokenSource(r *http.Request) *oauth.DBTokenSource { + ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) + if tid := store.TenantIDFromContext(r.Context()); tid != uuid.Nil { + ts.WithTenantID(tid) + } + return ts } func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) { - ts := h.newTokenSource() + ts := h.newTokenSource(r) if !ts.Exists(r.Context()) { writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) return @@ -83,7 +87,7 @@ func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) { defer h.mu.Unlock() // Already authenticated? - ts := h.newTokenSource() + ts := h.newTokenSource(r) if ts.Exists(r.Context()) { if _, err := ts.Token(); err == nil { writeJSON(w, http.StatusOK, map[string]any{"status": "already_authenticated"}) @@ -191,14 +195,18 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque } func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { - ts := h.newTokenSource() + ts := h.newTokenSource(r) if err := ts.Delete(r.Context()); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if h.providerReg != nil { - h.providerReg.Unregister(oauth.DefaultProviderName) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + h.providerReg.UnregisterForTenant(tid, oauth.DefaultProviderName) } emitAudit(h.msgBus, r, "oauth.logout", "oauth", "openai") @@ -207,7 +215,10 @@ func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { // saveAndRegister persists the OAuth result to DB and registers the CodexProvider in-memory. func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) { - ts := h.newTokenSource() + ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) + if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { + ts.WithTenantID(tid) + } providerID, err := ts.SaveOAuthResult(ctx, tokenResp) if err != nil { return uuid.Nil, err diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json index b274709d..683e1591 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "GoClaw Gateway API", - "description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.", + "description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.", "version": "0.2.0", "contact": { "name": "GoClaw", diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go index ec7294f2..bb3b3e93 100644 --- a/internal/http/pending_messages.go +++ b/internal/http/pending_messages.go @@ -127,7 +127,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re } // Resolve an LLM provider for summarization using the default agent's config - provider, model := h.resolveProviderAndModel() + provider, model := h.resolveProviderAndModel(r.Context()) if provider == nil { // Fallback: hard delete if no provider available slog.Warn("compact.no_provider", "channel", req.ChannelName, "key", req.HistoryKey) @@ -162,14 +162,14 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re // resolveProviderAndModel resolves the LLM provider+model for pending message compaction. // Priority: config provider/model > default agent's provider/model > first available provider. -func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, string) { +func (h *PendingMessagesHandler) resolveProviderAndModel(ctx context.Context) (providers.Provider, string) { if h.providerReg == nil { return nil, "" } // Config-level provider/model override. if h.cfgProvider != "" { - if p, err := h.providerReg.Get(h.cfgProvider); err == nil { + if p, err := h.providerReg.Get(ctx, h.cfgProvider); err == nil { model := h.cfgModel if model == "" { model = p.DefaultModel() @@ -182,8 +182,8 @@ func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, // Fallback: default agent's provider+model. if h.agentStore != nil { - if ag, err := h.agentStore.GetDefault(context.Background()); err == nil && ag.Provider != "" { - if p, err := h.providerReg.Get(ag.Provider); err == nil { + if ag, err := h.agentStore.GetDefault(ctx); err == nil && ag.Provider != "" { + if p, err := h.providerReg.GetForTenant(ag.TenantID, ag.Provider); err == nil { model := ag.Model if model == "" { model = p.DefaultModel() @@ -196,8 +196,8 @@ func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, } // Fallback: first provider with a valid default model - for _, name := range h.providerReg.List() { - p, err := h.providerReg.Get(name) + for _, name := range h.providerReg.List(ctx) { + p, err := h.providerReg.Get(ctx, name) if err != nil { continue } diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 161dea9a..3cd5aa85 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "net/http" "os/exec" "path/filepath" @@ -82,7 +83,9 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } - provider, err := h.providerReg.Get(p.Name) + // Use provider's own TenantID (not request context) so cross-tenant admins + // can verify providers belonging to other tenants. + provider, err := h.providerReg.GetForTenant(p.TenantID, p.Name) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider not registered: " + p.Name}) return @@ -95,7 +98,7 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() _, err = provider.Chat(ctx, providers.ChatRequest{ @@ -171,6 +174,14 @@ func isNonChatModel(model string) bool { // friendlyVerifyError extracts a human-readable message from provider errors. // Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}` func friendlyVerifyError(err error) string { + // Timeout / context cancellation → user-friendly message + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "context deadline exceeded") { + return "Verification timed out — the provider took too long to respond. Please try again." + } + if errors.Is(err, context.Canceled) { + return "Verification was cancelled. Please try again." + } + msg := err.Error() // Try to extract "message" field from embedded JSON diff --git a/internal/http/providers.go b/internal/http/providers.go index dabfa56c..cbc29c9f 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -129,7 +129,7 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { mcpData.AgentMCPLookup = h.mcpLookup cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData)) } - h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...)) return } if p.APIKey == "" { @@ -138,25 +138,25 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { apiBase := h.resolveAPIBase(p) switch p.ProviderType { case store.ProviderChatGPTOAuth: - ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name) - h.providerReg.Register(providers.NewCodexProvider(p.Name, ts, apiBase, "")) + ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, apiBase, "")) case store.ProviderAnthropicNative: - h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey, + h.providerReg.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(apiBase))) case store.ProviderDashScope: - h.providerReg.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, "")) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, "")) case store.ProviderBailian: base := apiBase if base == "" { base = "https://coding-intl.dashscope.aliyuncs.com/v1" } - h.providerReg.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "") if p.ProviderType == store.ProviderMiniMax { prov.WithChatPath("/text/chatcompletion_v2") } - h.providerReg.Register(prov) + h.providerReg.RegisterForTenant(p.TenantID, prov) } } @@ -311,10 +311,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R if updated, err := h.store.GetProvider(r.Context(), id); err == nil { // Unregister old name if renamed to prevent ghost entries if oldName != "" && oldName != updated.Name { - h.providerReg.Unregister(oldName) + h.providerReg.UnregisterForTenant(updated.TenantID, oldName) } if !updated.Enabled { - h.providerReg.Unregister(updated.Name) + h.providerReg.UnregisterForTenant(updated.TenantID, updated.Name) } else { h.registerInMemory(updated) } @@ -341,10 +341,12 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R return } - // Read provider name before deleting so we can unregister it + // Read provider before deleting so we can unregister it var providerName string + var providerTenantID uuid.UUID if p, err := h.store.GetProvider(r.Context(), id); err == nil { providerName = p.Name + providerTenantID = p.TenantID } if err := h.store.DeleteProvider(r.Context(), id); err != nil { @@ -354,7 +356,7 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R } if h.providerReg != nil && providerName != "" { - h.providerReg.Unregister(providerName) + h.providerReg.UnregisterForTenant(providerTenantID, providerName) } if providerName != "" { h.emitProviderCacheInvalidate(providerName) diff --git a/internal/http/responses.go b/internal/http/responses.go index 94f2e3b8..436235e9 100644 --- a/internal/http/responses.go +++ b/internal/http/responses.go @@ -80,7 +80,7 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { agentID := extractAgentID(r, req.Model) userID := extractUserID(r) - loop, err := h.agents.Get(agentID) + loop, err := h.agents.Get(r.Context(), agentID) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"agent not found: %s"}`, agentID), http.StatusNotFound) return diff --git a/internal/http/skills.go b/internal/http/skills.go index 38f0c75b..737ff01c 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -1,12 +1,17 @@ package http import ( + "context" "encoding/json" + "log/slog" "net/http" + "os" + "path/filepath" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/skills" @@ -19,15 +24,25 @@ const maxSkillUploadSize = 20 << 20 // 20 MB // SkillsHandler handles skill management HTTP endpoints. type SkillsHandler struct { - skills *pg.PGSkillStore - baseDir string // filesystem base for skill content - token string - msgBus *bus.MessageBus + skills *pg.PGSkillStore + baseDir string // filesystem base for skill content (skills-store/) — master tenant + dataDir string // parent data dir for tenant-scoped skill paths + bundledDir string // original bundled skills dir (fallback for broken managed copies) + token string + msgBus *bus.MessageBus } // NewSkillsHandler creates a handler for skill management endpoints. -func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, token string, msgBus *bus.MessageBus) *SkillsHandler { - return &SkillsHandler{skills: skills, baseDir: baseDir, token: token, msgBus: msgBus} +func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir, token string, msgBus *bus.MessageBus) *SkillsHandler { + return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, token: token, msgBus: msgBus} +} + +// tenantSkillsDir returns the skills-store directory scoped to the requesting tenant. +// Master tenant returns h.baseDir unchanged (backward compat). +func (h *SkillsHandler) tenantSkillsDir(r *http.Request) string { + tid := store.TenantIDFromContext(r.Context()) + slug := store.TenantSlugFromContext(r.Context()) + return config.TenantSkillsStoreDir(h.dataDir, tid, slug) } // emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set. @@ -56,26 +71,53 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions)) mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile)) mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles)) - mux.HandleFunc("POST /v1/skills/rescan-deps", h.authMiddleware(h.handleRescanDeps)) - mux.HandleFunc("POST /v1/skills/install-deps", h.authMiddleware(h.handleInstallDeps)) - mux.HandleFunc("POST /v1/skills/install-dep", h.authMiddleware(h.handleInstallDep)) - mux.HandleFunc("GET /v1/skills/runtimes", h.authMiddleware(h.handleRuntimes)) - mux.HandleFunc("POST /v1/skills/{id}/toggle", h.authMiddleware(h.handleToggle)) + // System-level operations: admin + master tenant only. + // These execute shell commands (pip/npm install) and affect the entire server. + mux.HandleFunc("POST /v1/skills/rescan-deps", h.adminMiddleware(h.handleRescanDeps)) + mux.HandleFunc("POST /v1/skills/install-deps", h.adminMiddleware(h.handleInstallDeps)) + mux.HandleFunc("POST /v1/skills/install-dep", h.adminMiddleware(h.handleInstallDep)) + mux.HandleFunc("GET /v1/skills/runtimes", h.adminMiddleware(h.handleRuntimes)) + mux.HandleFunc("POST /v1/skills/{id}/toggle", h.adminMiddleware(h.handleToggle)) } func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } +// adminMiddleware requires admin role — used for system-level operations +// (rescan deps, install packages, toggle skills) that affect the entire server. +func (h *SkillsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, permissions.RoleAdmin, next) +} + +// requireMasterTenant rejects requests from non-master tenants. +// System skill management (install packages, rescan deps) is a server-wide operation +// that should only be accessible to the master tenant or cross-tenant admins. +func (h *SkillsHandler) requireMasterTenant(w http.ResponseWriter, r *http.Request) bool { + ctx := r.Context() + if store.IsCrossTenant(ctx) { + return true + } + tid := store.TenantIDFromContext(ctx) + if tid == store.MasterTenantID { + return true + } + locale := store.LocaleFromContext(ctx) + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": i18n.T(locale, i18n.MsgPermissionDenied, "system skill management"), + }) + return false +} + func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) { - skills := h.skills.ListSkills() + skills := h.skills.ListSkills(r.Context()) writeJSON(w, http.StatusOK, map[string]any{"skills": skills}) } func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id := r.PathValue("id") - skill, ok := h.skills.GetSkill(id) + skill, ok := h.skills.GetSkill(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id)}) return @@ -96,7 +138,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -114,7 +156,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { delete(updates, "is_system") delete(updates, "enabled") - if err := h.skills.UpdateSkill(id, updates); err != nil { + if err := h.skills.UpdateSkill(r.Context(), id, updates); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -137,13 +179,13 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } } - if err := h.skills.DeleteSkill(id); err != nil { + if err := h.skills.DeleteSkill(r.Context(), id); err != nil { if err.Error() == "cannot delete system skill" { writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete system skill"}) return @@ -159,6 +201,9 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { // handleInstallDeps installs missing dependencies for all system skills, then re-checks status. func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } dirs := h.skills.ListSystemSkillDirs() if len(dirs) == 0 { writeJSON(w, http.StatusOK, map[string]string{"message": "no system skills"}) @@ -185,7 +230,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request } // Re-check all system skills and update status after install - allSkills := h.skills.ListAllSkills() + allSkills := h.skills.ListAllSkills(r.Context()) for _, sk := range allSkills { if !sk.IsSystem { continue @@ -194,8 +239,9 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request if !exists { continue } - m := skills.ScanSkillDeps(dir) + m := h.scanWithFallback(sk) if m == nil || m.IsEmpty() { + _ = dir // dir was used for direct scan; fallback uses sk.BaseDir continue } ok, miss := skills.CheckSkillDeps(m) @@ -204,7 +250,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request continue } if ok && sk.Status == "archived" { - _ = h.skills.UpdateSkill(id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": "active"}) h.skills.BumpVersion() } status := "active" @@ -236,6 +282,9 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request // handleInstallDep installs a single dependency and re-checks all skill statuses. // Body: {"dep": "pip:openpyxl"} func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } var body struct { Dep string `json:"dep"` } @@ -279,30 +328,40 @@ type depResult struct { // rescanAndUpdate re-checks all skills and updates their status + missing deps in DB. func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { - allSkills := h.skills.ListAllSkills() + allSkills := h.skills.ListAllSkills(store.WithCrossTenant(context.Background())) for _, sk := range allSkills { - manifest := skills.ScanSkillDeps(sk.BaseDir) - if manifest == nil || manifest.IsEmpty() { - results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) - continue - } + manifest := h.scanWithFallback(sk) - ok, missing := skills.CheckSkillDeps(manifest) id, err := uuid.Parse(sk.ID) if err != nil { continue } + if manifest == nil || manifest.IsEmpty() { + // No deps needed — if archived, recover to active and clear stale deps. + if sk.Status == "archived" { + _ = h.skills.StoreMissingDeps(id, nil) + _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"}) + results = append(results, depResult{Slug: sk.Slug, Status: "active"}) + updated++ + slog.Debug("rescan: recovered archived skill (no deps)", "slug", sk.Slug) + } else { + results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) + } + continue + } + + ok, missing := skills.CheckSkillDeps(manifest) _ = h.skills.StoreMissingDeps(id, missing) switch { case ok && sk.Status == "archived": - _ = h.skills.UpdateSkill(id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"}) results = append(results, depResult{Slug: sk.Slug, Status: "active"}) updated++ case !ok && sk.Status == "active": - _ = h.skills.UpdateSkill(id, map[string]any{"status": "archived"}) + _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "archived"}) results = append(results, depResult{Slug: sk.Slug, Status: "archived", Missing: missing}) updated++ case !ok: @@ -310,6 +369,8 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { default: results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) } + + slog.Debug("rescan: checked skill", "slug", sk.Slug, "ok", ok, "missing", len(missing)) } if updated > 0 { @@ -318,8 +379,49 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { return updated, results } +// scanWithFallback scans skill deps from the managed dir, falling back to the +// bundled dir if the managed copy's scripts/ directory is missing or empty. +// If a fallback scan succeeds, re-copies the bundled scripts to the managed dir. +func (h *SkillsHandler) scanWithFallback(sk store.SkillInfo) *skills.SkillManifest { + manifest := skills.ScanSkillDeps(sk.BaseDir) + if manifest != nil && !manifest.IsEmpty() { + return manifest + } + + // Fallback: try bundled dir for system skills whose managed copy is broken. + if !sk.IsSystem || h.bundledDir == "" { + return manifest + } + + managedScripts := filepath.Join(sk.BaseDir, "scripts") + if _, err := os.Stat(managedScripts); err == nil { + // scripts/ exists in managed dir but scanner found nothing — not a copy issue. + return manifest + } + + bundledSkillDir := filepath.Join(h.bundledDir, sk.Slug) + bundledManifest := skills.ScanSkillDeps(bundledSkillDir) + if bundledManifest == nil || bundledManifest.IsEmpty() { + return manifest + } + + slog.Warn("rescan: managed scripts/ missing, using bundled fallback", + "slug", sk.Slug, "managed", sk.BaseDir, "bundled", bundledSkillDir) + + // Re-copy bundled scripts to managed dir so future scans work without fallback. + bundledScripts := filepath.Join(bundledSkillDir, "scripts") + if err := skills.CopyDir(bundledScripts, managedScripts); err != nil { + slog.Error("rescan: failed to re-copy bundled scripts", "slug", sk.Slug, "error", err) + } + + return bundledManifest +} + // handleRescanDeps re-checks dependencies for all skills (including archived) and updates their status. func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } updated, results := h.rescanAndUpdate() writeJSON(w, http.StatusOK, map[string]any{ "updated": updated, @@ -336,6 +438,9 @@ func (h *SkillsHandler) handleRuntimes(w http.ResponseWriter, _ *http.Request) { // Body: {"enabled": bool} // When enabling: re-checks deps and updates status to "active" or "archived" accordingly. func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } locale := store.LocaleFromContext(r.Context()) idStr := r.PathValue("id") id, err := uuid.Parse(idStr) @@ -352,7 +457,7 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { return } - if err := h.skills.ToggleSkill(id, body.Enabled); err != nil { + if err := h.skills.ToggleSkill(r.Context(), id, body.Enabled); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -360,9 +465,9 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { newStatus := "" if body.Enabled { // Re-check deps for this skill so its status reflects reality after being re-enabled. - sk, ok := h.skills.GetSkillByID(id) + sk, ok := h.skills.GetSkillByID(r.Context(), id) if ok { - manifest := skills.ScanSkillDeps(sk.BaseDir) + manifest := h.scanWithFallback(sk) if manifest != nil && !manifest.IsEmpty() { depOk, missing := skills.CheckSkillDeps(manifest) _ = h.skills.StoreMissingDeps(id, missing) @@ -374,7 +479,7 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { } else { newStatus = "active" } - _ = h.skills.UpdateSkill(id, map[string]any{"status": newStatus}) + _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": newStatus}) } } diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index 3943144d..a19d4825 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -47,7 +47,7 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) // Ownership check (admins bypass) auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -96,7 +96,7 @@ func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -133,7 +133,7 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) // Ownership check (admins bypass) auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -179,7 +179,7 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index 384763d3..8912444d 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -117,8 +117,9 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { // Determine version (always increment — includes archived skills so re-upload gets v2+) version := h.skills.GetNextVersion(slug) - // Extract to filesystem: baseDir/slug/version/ - destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version)) + // Extract to filesystem: tenant-scoped skills-store/slug/version/ + tenantSkillsBase := h.tenantSkillsDir(r) + destDir := filepath.Join(tenantSkillsBase, slug, fmt.Sprintf("%d", version)) if err := os.MkdirAll(destDir, 0755); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill directory")}) return @@ -200,7 +201,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { ok, missing := skills.CheckSkillDeps(manifest) if !ok { // Set skill to archived due to missing deps - _ = h.skills.UpdateSkill(id, map[string]any{"status": "archived"}) + _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": "archived"}) response["deps_warning"] = "missing dependencies: " + skills.FormatMissing(missing) } } diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go index 71579bc2..b63285d7 100644 --- a/internal/http/skills_versions.go +++ b/internal/http/skills_versions.go @@ -26,7 +26,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques return } - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return @@ -73,7 +73,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) return } - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return @@ -162,7 +162,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { return } - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return diff --git a/internal/http/storage.go b/internal/http/storage.go index 8f3e2dcf..4cbc94f1 100644 --- a/internal/http/storage.go +++ b/internal/http/storage.go @@ -13,24 +13,28 @@ import ( "sync" "time" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // StorageHandler provides HTTP endpoints for browsing and managing // files inside the ~/.goclaw/ data directory. // Skills directories are browsable (read-only) but deletion is blocked. +// sizeCacheEntry holds a cached storage size calculation for one tenant. +type sizeCacheEntry struct { + total int64 + files int + cachedAt time.Time +} + type StorageHandler struct { - baseDir string // resolved absolute path to ~/.goclaw/ + baseDir string // global data dir (resolved absolute path to ~/.goclaw/) token string - // sizeCache caches the total storage size for 60 minutes. - sizeCache struct { - mu sync.Mutex - total int64 - files int - cachedAt time.Time - } + // sizeCache caches the total storage size per tenant for 60 minutes. + sizeCache sync.Map // tenantBaseDir (string) → *sizeCacheEntry } // NewStorageHandler creates a handler for workspace storage management. @@ -50,6 +54,14 @@ func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } +// tenantBaseDir resolves the data directory scoped to the requesting tenant. +// Master tenant returns the global baseDir (backward compat). +func (h *StorageHandler) tenantBaseDir(r *http.Request) string { + tid := store.TenantIDFromContext(r.Context()) + slug := store.TenantSlugFromContext(r.Context()) + return config.TenantDataDir(h.baseDir, tid, slug) +} + // protectedDirs are top-level directories where deletion is blocked // (managed separately via the Skills page). var protectedDirs = []string{"skills", "skills-store"} @@ -91,11 +103,12 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { } } - rootDir := h.baseDir + base := h.tenantBaseDir(r) + rootDir := base if subPath != "" { - rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath)) - if !strings.HasPrefix(rootDir, h.baseDir) { - slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir) + rootDir = filepath.Join(base, filepath.Clean(subPath)) + if !strings.HasPrefix(rootDir, base) { + slog.Warn("security.storage_escape", "resolved", rootDir, "root", base) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } @@ -119,7 +132,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { if path == rootDir { return nil } - rel, _ := filepath.Rel(h.baseDir, path) + rel, _ := filepath.Rel(base, path) // Skip symlinks if d.Type()&os.ModeSymlink != 0 { @@ -183,7 +196,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "files": entries, - "baseDir": h.baseDir, + "baseDir": base, }) } @@ -205,34 +218,33 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) - // Check cache - h.sizeCache.mu.Lock() - if !h.sizeCache.cachedAt.IsZero() && time.Since(h.sizeCache.cachedAt) < sizeCacheTTL { - total := h.sizeCache.total - files := h.sizeCache.files - h.sizeCache.mu.Unlock() - writeSizeEvent(w, flusher, map[string]any{"total": total, "files": files, "done": true, "cached": true}) - return + sizeBase := h.tenantBaseDir(r) + + // Check per-tenant cache + if entry, ok := h.sizeCache.Load(sizeBase); ok { + ce := entry.(*sizeCacheEntry) + if time.Since(ce.cachedAt) < sizeCacheTTL { + writeSizeEvent(w, flusher, map[string]any{"total": ce.total, "files": ce.files, "done": true, "cached": true}) + return + } } - h.sizeCache.mu.Unlock() // Walk and stream progress var total int64 var fileCount int lastFlush := time.Now() - filepath.WalkDir(h.baseDir, func(path string, d os.DirEntry, err error) error { + filepath.WalkDir(sizeBase, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } - // Check client disconnect if r.Context().Err() != nil { return filepath.SkipAll } if d.Type()&os.ModeSymlink != 0 { return nil } - rel, _ := filepath.Rel(h.baseDir, path) + rel, _ := filepath.Rel(sizeBase, path) if skills.IsSystemArtifact(rel) { return nil } @@ -240,7 +252,6 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { total += info.Size() fileCount++ } - // Emit progress every 50 files or 200ms if fileCount%50 == 0 || time.Since(lastFlush) > 200*time.Millisecond { writeSizeEvent(w, flusher, map[string]any{"current": total, "files": fileCount}) lastFlush = time.Now() @@ -248,12 +259,8 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { return nil }) - // Update cache - h.sizeCache.mu.Lock() - h.sizeCache.total = total - h.sizeCache.files = fileCount - h.sizeCache.cachedAt = time.Now() - h.sizeCache.mu.Unlock() + // Update per-tenant cache + h.sizeCache.Store(sizeBase, &sizeCacheEntry{total: total, files: fileCount, cachedAt: time.Now()}) // Send final event writeSizeEvent(w, flusher, map[string]any{"total": total, "files": fileCount, "done": true, "cached": false}) @@ -279,9 +286,10 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) { return } - absPath := filepath.Join(h.baseDir, filepath.Clean(relPath)) - if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) { - slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir) + readBase := h.tenantBaseDir(r) + absPath := filepath.Join(readBase, filepath.Clean(relPath)) + if !strings.HasPrefix(absPath, readBase+string(filepath.Separator)) { + slog.Warn("security.storage_escape", "resolved", absPath, "root", readBase) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } @@ -346,9 +354,10 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) { return } - absPath := filepath.Join(h.baseDir, filepath.Clean(relPath)) - if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) { - slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir) + delBase := h.tenantBaseDir(r) + absPath := filepath.Join(delBase, filepath.Clean(relPath)) + if !strings.HasPrefix(absPath, delBase+string(filepath.Separator)) { + slog.Warn("security.storage_escape", "resolved", absPath, "root", delBase) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } diff --git a/internal/http/summoner.go b/internal/http/summoner.go index 8713350f..4e3feeb9 100644 --- a/internal/http/summoner.go +++ b/internal/http/summoner.go @@ -72,12 +72,12 @@ const singleCallTimeout = 300 * time.Second // On retry (resummon), skips files that were already generated (differ from template). // On success: stores generated files and sets agent status to "active". // On failure: keeps template files (already seeded) and sets status to store.AgentStatusSummonFailed. -func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) { - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Second) +func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, tenantID uuid.UUID, providerName, model, description string) { + ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 600*time.Second) defer cancel() s.ensureUserPredefined(ctx, agentID) - s.emitEvent(agentID, SummonEventStarted, "", "") + s.emitEvent(agentID, tenantID, SummonEventStarted, "", "") // Check which files already exist (from a previous partial run) existingMap := s.loadExistingFiles(ctx, agentID) @@ -85,9 +85,9 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc // Skip if all files already generated if s.isGenerated(existingMap, bootstrap.SoulFile) && s.isGenerated(existingMap, bootstrap.IdentityFile) { slog.Info("summoning: all files already generated, skipping", "agent", agentID) - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") - s.finishSummon(ctx, agentID, existingMap[bootstrap.IdentityFile], "", description) + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "") + s.finishSummon(ctx, agentID, tenantID, existingMap[bootstrap.IdentityFile], "", description) return } @@ -98,15 +98,15 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if err == nil { slog.Info("summoning: single-call succeeded", "agent", agentID) - s.storeFiles(ctx, agentID, files) - s.finishSummon(ctx, agentID, files[bootstrap.IdentityFile], files[frontmatterKey], description) + s.storeFiles(ctx, agentID, tenantID, files) + s.finishSummon(ctx, agentID, tenantID, files[bootstrap.IdentityFile], files[frontmatterKey], description) return } // Non-retryable error → fail immediately if !isRetryableError(err) { slog.Warn("summoning: single-call failed (non-retryable)", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -124,12 +124,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if s.isGenerated(existingMap, bootstrap.SoulFile) { soulContent = existingMap[bootstrap.SoulFile] slog.Info("summoning: SOUL.md already generated, skipping", "agent", agentID) - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "") } else { soulFiles, soulErr := s.generateFiles(ctx, providerName, model, s.buildSoulPrompt(description)) if soulErr != nil { slog.Warn("summoning: SOUL.md generation failed", "agent", agentID, "error", soulErr) - s.emitEvent(agentID, SummonEventFailed, "", soulErr.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", soulErr.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -139,7 +139,7 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.SoulFile, soulContent); storeErr != nil { slog.Warn("summoning: failed to store SOUL.md", "agent", agentID, "error", storeErr) } else { - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "") } } } @@ -152,12 +152,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if !identityNeeded && !userPredNeeded { identityContent = existingMap[bootstrap.IdentityFile] slog.Info("summoning: IDENTITY.md + USER_PREDEFINED.md already generated, skipping", "agent", agentID) - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "") } else { idFiles, idErr := s.generateFiles(ctx, providerName, model, s.buildIdentityPrompt(description, soulContent)) if idErr != nil { slog.Warn("summoning: IDENTITY.md generation failed", "agent", agentID, "error", idErr) - s.emitEvent(agentID, SummonEventFailed, "", idErr.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", idErr.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -169,23 +169,23 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.IdentityFile, identityContent); storeErr != nil { slog.Warn("summoning: failed to store IDENTITY.md", "agent", agentID, "error", storeErr) } else { - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "") } } if upContent := idFiles[bootstrap.UserPredefinedFile]; upContent != "" && userPredNeeded { if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, upContent); storeErr != nil { slog.Warn("summoning: failed to store USER_PREDEFINED.md", "agent", agentID, "error", storeErr) } else { - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "") } } } - s.finishSummon(ctx, agentID, identityContent, frontmatter, description) + s.finishSummon(ctx, agentID, tenantID, identityContent, frontmatter, description) } // finishSummon saves agent metadata and marks the agent as active. -func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, identityContent, frontmatter, description string) { +func (s *AgentSummoner) finishSummon(ctx context.Context, agentID, tenantID uuid.UUID, identityContent, frontmatter, description string) { updates := map[string]any{} if frontmatter == "" { frontmatter = truncateUTF8(description, 200) @@ -202,7 +202,7 @@ func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, ide } } s.setAgentStatus(ctx, agentID, store.AgentStatusActive) - s.emitEvent(agentID, SummonEventCompleted, "", "") + s.emitEvent(agentID, tenantID, SummonEventCompleted, "", "") slog.Info("summoning: completed", "agent", agentID) } diff --git a/internal/http/summoner_regenerate.go b/internal/http/summoner_regenerate.go index 937a78cb..9f182dcc 100644 --- a/internal/http/summoner_regenerate.go +++ b/internal/http/summoner_regenerate.go @@ -20,19 +20,19 @@ import ( // RegenerateAgent updates context files based on an edit prompt. // Reads existing files, sends them + edit instructions to LLM, stores results. // Synchronous — caller should run in goroutine if needed. -func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) { - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) +func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, tenantID uuid.UUID, providerName, model, editPrompt string) { + ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 300*time.Second) defer cancel() s.ensureUserPredefined(ctx, agentID) - s.emitEvent(agentID, SummonEventStarted, "", "") + s.emitEvent(agentID, tenantID, SummonEventStarted, "", "") // Read existing files for context existing, err := s.agents.GetAgentContextFiles(ctx, agentID) if err != nil { slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -42,13 +42,13 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, files, err := s.generateFiles(ctx, providerName, model, prompt) if err != nil { slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error()) // Use fresh context — the original may have timed out, but we still need to update status. s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } - s.storeFiles(ctx, agentID, files) + s.storeFiles(ctx, agentID, tenantID, files) // Update frontmatter + display_name if IDENTITY.md was regenerated updates := map[string]any{} @@ -65,7 +65,7 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, } s.setAgentStatus(ctx, agentID, store.AgentStatusActive) - s.emitEvent(agentID, SummonEventCompleted, "", "") + s.emitEvent(agentID, tenantID, SummonEventCompleted, "", "") slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files)) } @@ -98,7 +98,7 @@ func (s *AgentSummoner) isGenerated(existingMap map[string]string, fileName stri // generateFiles calls the LLM and parses the XML-tagged response into file map. func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) { - provider, err := s.resolveProvider(providerName) + provider, err := s.resolveProvider(ctx, providerName) if err != nil { return nil, fmt.Errorf("resolve provider: %w", err) } @@ -139,7 +139,7 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, } // storeFiles saves generated files to agent_context_files and emits progress events. -func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) { +func (s *AgentSummoner) storeFiles(ctx context.Context, agentID, tenantID uuid.UUID, files map[string]string) { for _, name := range summoningFiles { content, ok := files[name] if !ok || content == "" { @@ -149,23 +149,23 @@ func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err) continue } - s.emitEvent(agentID, SummonEventFileGenerated, name, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, name, "") } } -func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) { +func (s *AgentSummoner) resolveProvider(ctx context.Context, name string) (providers.Provider, error) { if s.providerReg == nil { return nil, fmt.Errorf("no provider registry") } - provider, err := s.providerReg.Get(name) + provider, err := s.providerReg.Get(ctx, name) if err != nil { // Fallback to first available provider - names := s.providerReg.List() + names := s.providerReg.List(ctx) if len(names) == 0 { return nil, fmt.Errorf("no providers configured") } - provider, err = s.providerReg.Get(names[0]) + provider, err = s.providerReg.Get(ctx, names[0]) if err != nil { return nil, err } @@ -197,7 +197,7 @@ func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, s } } -func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) { +func (s *AgentSummoner) emitEvent(agentID, tenantID uuid.UUID, eventType, fileName, errMsg string) { if s.msgBus == nil { return } @@ -211,8 +211,5 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg if errMsg != "" { payload["error"] = errMsg } - s.msgBus.Broadcast(bus.Event{ - Name: protocol.EventAgentSummoning, - Payload: payload, - }) + bus.BroadcastForTenant(s.msgBus, protocol.EventAgentSummoning, tenantID, payload) } diff --git a/internal/http/team_attachments.go b/internal/http/team_attachments.go index 65bfb2dc..90350974 100644 --- a/internal/http/team_attachments.go +++ b/internal/http/team_attachments.go @@ -30,14 +30,27 @@ func (h *TeamAttachmentsHandler) RegisterRoutes(mux *http.ServeMux) { func (h *TeamAttachmentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provided := extractBearerToken(r) - if provided == "" { - provided = r.URL.Query().Get("token") - } - if !requireAuthBearer(h.token, "", provided, w, r) { + // Priority 1: HMAC-signed file token (?ft=) — no gateway token exposure. + if ft := r.URL.Query().Get("ft"); ft != "" { + path := r.URL.Path // full path for HMAC binding + if VerifyFileToken(ft, path, FileSigningKey()) { + next(w, r) + return + } + http.Error(w, "invalid or expired file token", http.StatusUnauthorized) return } - next(w, r) + // Priority 2: Bearer header (API clients). + provided := extractBearerToken(r) + if provided == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + authedReq, ok := requireAuthBearer(h.token, "", provided, w, r) + if !ok { + return + } + next(w, authedReq) } } @@ -73,13 +86,18 @@ func (h *TeamAttachmentsHandler) handleDownload(w http.ResponseWriter, r *http.R return } - // Resolve absolute disk path: {dataDir}/teams/{teamID}/{chatID}/{path} - absPath := filepath.Join(h.dataDir, "teams", att.TeamID.String(), att.ChatID, att.Path) - - // Security: path traversal check — resolved path must stay within team workspace. - teamBase := filepath.Join(h.dataDir, "teams", att.TeamID.String()) - cleanPath := filepath.Clean(absPath) - if !strings.HasPrefix(cleanPath, teamBase+string(filepath.Separator)) && cleanPath != teamBase { + // Resolve disk path. Absolute paths (new) are used directly; + // relative paths (legacy) are joined with the team workspace directory. + var cleanPath string + if filepath.IsAbs(att.Path) { + cleanPath = filepath.Clean(att.Path) + } else { + // Legacy: {workspace}/teams/{teamID}/{chatID}/{relPath} + cleanPath = filepath.Clean(filepath.Join(h.dataDir, "teams", att.TeamID.String(), att.ChatID, att.Path)) + } + // Security: file must be within the workspace root to prevent path traversal. + wsRoot := filepath.Clean(h.dataDir) + string(filepath.Separator) + if !strings.HasPrefix(cleanPath, wsRoot) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid attachment path"}) return } diff --git a/internal/http/tenant_cache.go b/internal/http/tenant_cache.go new file mode 100644 index 00000000..2091b467 --- /dev/null +++ b/internal/http/tenant_cache.go @@ -0,0 +1,95 @@ +package http + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// tenantCacheEntry holds a cached tenant lookup result. +type tenantCacheEntry struct { + tenant *store.TenantData + fetchedAt time.Time +} + +// tenantCache is a TTL cache for tenant lookups by UUID and slug. +// Invalidated via bus CacheKindTenants events. +type tenantCache struct { + mu sync.RWMutex + byID map[uuid.UUID]*tenantCacheEntry + bySlug map[string]*tenantCacheEntry + ttl time.Duration + store store.TenantStore +} + +func newTenantCache(s store.TenantStore, ttl time.Duration) *tenantCache { + return &tenantCache{ + byID: make(map[uuid.UUID]*tenantCacheEntry), + bySlug: make(map[string]*tenantCacheEntry), + ttl: ttl, + store: s, + } +} + +// GetTenant returns a tenant by UUID, using cache when available. +func (c *tenantCache) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) { + c.mu.RLock() + if e, ok := c.byID[id]; ok && time.Since(e.fetchedAt) <= c.ttl { + c.mu.RUnlock() + slog.Debug("tenant_cache.hit", "id", id) + return e.tenant, nil + } + c.mu.RUnlock() + slog.Debug("tenant_cache.miss", "id", id) + + t, err := c.store.GetTenant(ctx, id) + if err != nil { + return nil, err + } + if t != nil { + c.put(t) + } + return t, nil +} + +// GetTenantBySlug returns a tenant by slug, using cache when available. +func (c *tenantCache) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) { + c.mu.RLock() + if e, ok := c.bySlug[slug]; ok && time.Since(e.fetchedAt) <= c.ttl { + c.mu.RUnlock() + slog.Debug("tenant_cache.hit", "slug", slug) + return e.tenant, nil + } + c.mu.RUnlock() + slog.Debug("tenant_cache.miss", "slug", slug) + + t, err := c.store.GetTenantBySlug(ctx, slug) + if err != nil { + return nil, err + } + if t != nil { + c.put(t) + } + return t, nil +} + +func (c *tenantCache) put(t *store.TenantData) { + entry := &tenantCacheEntry{tenant: t, fetchedAt: time.Now()} + c.mu.Lock() + c.byID[t.ID] = entry + c.bySlug[t.Slug] = entry + c.mu.Unlock() +} + +// invalidateAll clears all cached entries. Called on bus cache.invalidate events. +func (c *tenantCache) invalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + slog.Debug("tenant_cache.invalidated", "entries", len(c.byID)) + c.byID = make(map[uuid.UUID]*tenantCacheEntry) + c.bySlug = make(map[string]*tenantCacheEntry) +} diff --git a/internal/http/tenants.go b/internal/http/tenants.go new file mode 100644 index 00000000..4e9ddc1b --- /dev/null +++ b/internal/http/tenants.go @@ -0,0 +1,312 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + "path/filepath" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// TenantsHandler handles tenant CRUD and membership endpoints. +type TenantsHandler struct { + tenantStore store.TenantStore + token string + msgBus *bus.MessageBus + workspace string // base workspace directory for tenant dirs +} + +// NewTenantsHandler creates a handler for tenant management endpoints. +func NewTenantsHandler(tenantStore store.TenantStore, token string, msgBus *bus.MessageBus, workspace string) *TenantsHandler { + return &TenantsHandler{tenantStore: tenantStore, token: token, msgBus: msgBus, workspace: workspace} +} + +// RegisterRoutes registers all tenant management routes on the given mux. +func (h *TenantsHandler) RegisterRoutes(mux *http.ServeMux) { + admin := func(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, permissions.RoleAdmin, next) + } + mux.HandleFunc("GET /v1/tenants", admin(h.handleList)) + mux.HandleFunc("POST /v1/tenants", admin(h.handleCreate)) + mux.HandleFunc("GET /v1/tenants/{id}", admin(h.handleGet)) + mux.HandleFunc("PATCH /v1/tenants/{id}", admin(h.handleUpdate)) + mux.HandleFunc("GET /v1/tenants/{id}/users", admin(h.handleUsersList)) + mux.HandleFunc("POST /v1/tenants/{id}/users", admin(h.handleUsersAdd)) + mux.HandleFunc("DELETE /v1/tenants/{id}/users/{userId}", admin(h.handleUsersRemove)) +} + +func (h *TenantsHandler) handleList(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")}) + return + } + + tenants, err := h.tenantStore.ListTenants(r.Context()) + if err != nil { + slog.Error("tenants.list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenants")}) + return + } + if tenants == nil { + tenants = []store.TenantData{} + } + writeJSON(w, http.StatusOK, map[string]any{"tenants": tenants}) +} + +func (h *TenantsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")}) + return + } + + var input struct { + Name string `json:"name"` + Slug string `json:"slug"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if input.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")}) + return + } + if input.Slug == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "slug")}) + return + } + if !isValidSlug(input.Slug) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "slug")}) + return + } + + tenant := &store.TenantData{ + ID: store.GenNewID(), + Name: input.Name, + Slug: input.Slug, + Status: store.TenantStatusActive, + } + + if err := h.tenantStore.CreateTenant(r.Context(), tenant); err != nil { + slog.Error("tenants.create failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error())}) + return + } + + // Create workspace directory for the tenant. + if h.workspace != "" { + tenantDir := filepath.Join(h.workspace, "tenants", tenant.Slug) + if err := os.MkdirAll(tenantDir, 0755); err != nil { + slog.Warn("tenants.create: failed to create workspace dir", "dir", tenantDir, "error", err) + } + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String()) + emitAudit(h.msgBus, r, "tenant.created", "tenant", tenant.ID.String()) + writeJSON(w, http.StatusCreated, tenant) +} + +func (h *TenantsHandler) handleGet(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + tenant, err := h.tenantStore.GetTenant(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tenant", id.String())}) + return + } + writeJSON(w, http.StatusOK, tenant) +} + +func (h *TenantsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + var input struct { + Name string `json:"name"` + Status string `json:"status"` + Settings map[string]any `json:"settings"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + updates := make(map[string]any) + if input.Name != "" { + updates["name"] = input.Name + } + if input.Status != "" { + updates["status"] = input.Status + } + if input.Settings != nil { + updates["settings"] = input.Settings + } + + if len(updates) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidUpdates)}) + return + } + + if err := h.tenantStore.UpdateTenant(r.Context(), id, updates); err != nil { + slog.Error("tenants.update failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error())}) + return + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String()) + emitAudit(h.msgBus, r, "tenant.updated", "tenant", id.String()) + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (h *TenantsHandler) handleUsersList(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + users, err := h.tenantStore.ListUsers(r.Context(), id) + if err != nil { + slog.Error("tenants.users.list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")}) + return + } + if users == nil { + users = []store.TenantUserData{} + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (h *TenantsHandler) handleUsersAdd(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + var input struct { + UserID string `json:"user_id"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if input.UserID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")}) + return + } + if input.Role == "" { + input.Role = store.TenantRoleMember + } + validRoles := map[string]bool{ + store.TenantRoleOwner: true, store.TenantRoleAdmin: true, + store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true, + } + if !validRoles[input.Role] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRole)}) + return + } + + if err := h.tenantStore.AddUser(r.Context(), id, input.UserID, input.Role); err != nil { + slog.Error("tenants.users.add failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())}) + return + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, input.UserID) + emitAudit(h.msgBus, r, "tenant.user.added", "tenant", id.String()) + writeJSON(w, http.StatusCreated, map[string]string{"ok": "true"}) +} + +func (h *TenantsHandler) handleUsersRemove(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + userID := r.PathValue("userId") + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userId")}) + return + } + + if err := h.tenantStore.RemoveUser(r.Context(), id, userID); err != nil { + slog.Error("tenants.users.remove failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error())}) + return + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, userID) + emitAudit(h.msgBus, r, "tenant.user.removed", "tenant", id.String()) + + // Notify affected user's WS sessions to force logout + if h.msgBus != nil { + h.msgBus.Broadcast(bus.Event{ + Name: protocol.EventTenantAccessRevoked, + Payload: map[string]string{"user_id": userID, "tenant_id": id.String()}, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (h *TenantsHandler) emitCacheInvalidate(kind, key string) { + if h.msgBus == nil { + return + } + h.msgBus.Broadcast(bus.Event{ + Name: protocol.EventCacheInvalidate, + Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key}, + }) +} diff --git a/internal/http/traces.go b/internal/http/traces.go index 90e2311f..383d1ed3 100644 --- a/internal/http/traces.go +++ b/internal/http/traces.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -73,6 +74,13 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) { } } + // Non-admin callers may only see their own traces. + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + opts.UserID = callerID + } + traces, err := h.tracing.ListTraces(r.Context(), opts) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -104,6 +112,16 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) { return } + // Non-admin callers may only access their own traces. + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + if trace.UserID != callerID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceIDStr)}) + return + } + } + spans, err := h.tracing.GetTraceSpans(r.Context(), traceID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -160,6 +178,21 @@ func (h *TracesHandler) handleExport(w http.ResponseWriter, r *http.Request) { return } + // Verify ownership before export. + rootTrace, err := h.tracing.GetTrace(r.Context(), traceID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())}) + return + } + authExport := resolveAuth(r, h.token) + if !permissions.HasMinRole(authExport.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + if rootTrace.UserID != callerID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())}) + return + } + } + entry, err := h.collectTraceTree(r.Context(), traceID, 0) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())}) diff --git a/internal/http/usage.go b/internal/http/usage.go index 5d75e627..13a923bb 100644 --- a/internal/http/usage.go +++ b/internal/http/usage.go @@ -185,6 +185,17 @@ func (h *UsageHandler) queryLiveHour(r *http.Request, from, to time.Time, q stor args := []any{from, to} idx := 3 + + // Tenant isolation: scope to caller's tenant + if !store.IsCrossTenant(r.Context()) { + tid := store.TenantIDFromContext(r.Context()) + if tid != uuid.Nil { + query += fmt.Sprintf(" AND tenant_id = $%d", idx) + args = append(args, tid) + idx++ + } + } + if q.AgentID != nil { query += fmt.Sprintf(" AND agent_id = $%d", idx) args = append(args, *q.AgentID) diff --git a/internal/http/validate.go b/internal/http/validate.go index 73c4c95c..e38178e2 100644 --- a/internal/http/validate.go +++ b/internal/http/validate.go @@ -35,7 +35,7 @@ var agentAllowedFields = map[string]bool{ "agent_key": true, "agent_type": true, "display_name": true, "provider": true, "model": true, "status": true, "context_window": true, "max_tool_iterations": true, - "workspace": true, "restrict_to_workspace": true, + "workspace": true, "frontmatter": true, "compaction_config": true, "memory_config": true, "other_config": true, "tools_config": true, "is_default": true, "budget_monthly_cents": true, "subagents_config": true, @@ -59,7 +59,7 @@ var mcpServerAllowedFields = map[string]bool{ "name": true, "transport": true, "command": true, "args": true, "url": true, "api_key": true, "env": true, "headers": true, "enabled": true, "tool_prefix": true, "timeout_seconds": true, - "agent_id": true, "config": true, + "agent_id": true, "config": true, "settings": true, } var channelInstanceAllowedFields = map[string]bool{ diff --git a/internal/http/wake.go b/internal/http/wake.go index 5f675b22..3cf3ddc4 100644 --- a/internal/http/wake.go +++ b/internal/http/wake.go @@ -93,7 +93,7 @@ func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) { return } - loop, err := h.agents.Get(agentID) + loop, err := h.agents.Get(r.Context(), agentID) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID)}) return diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 24de5217..103aef00 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -93,9 +93,6 @@ func init() { MsgAgentIsTeamLead: "agent is already the team lead", MsgCannotRemoveTeamLead: "cannot remove the team lead", - // Delegations - MsgDelegationsUnavailable: "delegations not available", - // Channels MsgCannotDeleteDefaultInst: "cannot delete default channel instance", @@ -176,5 +173,13 @@ func init() { MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.", MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.", MsgSkillNudge90Pct: "[System] You are at 90% of your iteration budget. If this session involved reusable patterns, consider saving them as a skill before completing.", + + MsgInvalidRole: "invalid role: allowed values are owner, admin, operator, member, viewer", + + MsgContactIDsRequired: "contact_ids is required", + MsgMergeTargetRequired: "exactly one of tenant_user_id or create_user is required", + MsgTenantUserNotFound: "tenant user not found", + MsgTenantMismatch: "tenant user does not belong to this tenant", + MsgTenantScopeRequired: "tenant scope is required for this operation", }) } diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index ae2036ab..49e8720c 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -93,9 +93,6 @@ func init() { MsgAgentIsTeamLead: "agent đã là trưởng nhóm", MsgCannotRemoveTeamLead: "không thể xóa trưởng nhóm", - // Delegations - MsgDelegationsUnavailable: "ủy quyền không khả dụng", - // Channels MsgCannotDeleteDefaultInst: "không thể xóa phiên bản channel mặc định", @@ -176,5 +173,13 @@ func init() { MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.", MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.", MsgSkillNudge90Pct: "[System] Bạn đã dùng 90% ngân sách vòng lặp. Nếu phiên này có quy trình tái sử dụng, hãy cân nhắc lưu thành kỹ năng trước khi hoàn thành.", + + MsgInvalidRole: "vai trò không hợp lệ: giá trị cho phép là owner, admin, operator, member, viewer", + + MsgContactIDsRequired: "contact_ids là bắt buộc", + MsgMergeTargetRequired: "cần chính xác một trong tenant_user_id hoặc create_user", + MsgTenantUserNotFound: "không tìm thấy tenant user", + MsgTenantMismatch: "tenant user không thuộc tenant này", + MsgTenantScopeRequired: "cần xác định tenant để thực hiện thao tác này", }) } diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index 2a806dc7..0402aaf8 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -93,9 +93,6 @@ func init() { MsgAgentIsTeamLead: "该Agent已是团队负责人", MsgCannotRemoveTeamLead: "无法移除团队负责人", - // Delegations - MsgDelegationsUnavailable: "委派功能不可用", - // Channels MsgCannotDeleteDefaultInst: "无法删除默认Channel实例", @@ -176,5 +173,13 @@ func init() { MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。", MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。", MsgSkillNudge90Pct: "[System] 您已使用 90% 的迭代预算。如果本次会话涉及可重用的模式,请考虑在完成前将其保存为技能。", + + MsgInvalidRole: "无效角色:允许的值为 owner、admin、operator、member、viewer", + + MsgContactIDsRequired: "contact_ids 为必填项", + MsgMergeTargetRequired: "必须提供 tenant_user_id 或 create_user 其中之一", + MsgTenantUserNotFound: "未找到租户用户", + MsgTenantMismatch: "租户用户不属于此租户", + MsgTenantScopeRequired: "此操作需要指定租户范围", }) } diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index f7909ed2..b090f23b 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -94,9 +94,6 @@ const ( MsgAgentIsTeamLead = "error.agent_is_team_lead" // "agent is already the team lead" MsgCannotRemoveTeamLead = "error.cannot_remove_team_lead" // "cannot remove the team lead" - // --- Delegations --- - MsgDelegationsUnavailable = "error.delegations_not_available" // "delegations not available" - // --- Channels --- MsgCannotDeleteDefaultInst = "error.cannot_delete_default_inst" // "cannot delete default channel instance" @@ -178,4 +175,14 @@ const ( MsgSkillNudgePostscript = "skill.nudge_postscript" MsgSkillNudge70Pct = "skill.nudge_70_pct" MsgSkillNudge90Pct = "skill.nudge_90_pct" + + // --- Tenants --- + MsgInvalidRole = "error.invalid_role" // "invalid role: allowed values are owner, admin, operator, member, viewer" + + // --- Contact merge --- + MsgContactIDsRequired = "error.contact_ids_required" // "contact_ids is required" + MsgMergeTargetRequired = "error.merge_target_required" // "exactly one of tenant_user_id or create_user is required" + MsgTenantUserNotFound = "error.tenant_user_not_found" // "tenant user not found" + MsgTenantMismatch = "error.tenant_mismatch" // "tenant user does not belong to this tenant" + MsgTenantScopeRequired = "error.tenant_scope_required" // "tenant scope is required for this operation" ) diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index f715450d..606dd496 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "log/slog" "sync" @@ -76,6 +77,7 @@ type Manager struct { pool *Pool poolServers map[string]struct{} // server names acquired from pool (for cleanup) poolToolNames map[string][]string // per-agent tool names for pool-backed servers + poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release // Search mode: deferred tools not registered in registry deferredTools map[string]*BridgeTool // registeredName → BridgeTool @@ -167,13 +169,69 @@ func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID st continue } + // Skip server if it requires per-user credentials and user has none + if requireUserCreds(srv.Settings) { + if userID == "" { + continue + } + uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID) + if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) { + slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID) + continue + } + } + args := jsonBytesToStringSlice(srv.Args) env := jsonBytesToStringMap(srv.Env) headers := jsonBytesToStringMap(srv.Headers) - if m.pool != nil { + // Inject APIKey into headers if present (bug fix: was never passed to connections) + if srv.APIKey != "" && headers["Authorization"] == "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + srv.APIKey + } + + // Merge per-user credentials (user overrides server defaults) + if userID != "" && m.store != nil { + if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil { + if userCreds.APIKey != "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + userCreds.APIKey + } + for k, v := range userCreds.Headers { + if headers == nil { + headers = make(map[string]string) + } + headers[k] = v + } + for k, v := range userCreds.Env { + if env == nil { + env = make(map[string]string) + } + env[k] = v + } + } + } + + // Per-user credentials change connection params → can't share pool connection. + // Fall back to per-agent mode when user has custom credentials. + hasUserCreds := userID != "" && m.store != nil + if hasUserCreds { + if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) { + hasUserCreds = true + } else { + hasUserCreds = false + } + } + + if m.pool != nil && !hasUserCreds { // Pool mode: acquire shared connection, create per-agent BridgeTools - if err := m.connectViaPool(ctx, srv.Name, srv.Transport, srv.Command, + tid := store.TenantIDFromContext(ctx) + if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command, args, env, srv.URL, headers, srv.ToolPrefix, srv.TimeoutSec); err != nil { slog.Warn("mcp.server.connect_failed", "server", srv.Name, "error", err) continue @@ -359,7 +417,9 @@ func (m *Manager) Stop() { m.registry.Unregister(toolName) } if m.pool != nil { - m.pool.Release(name) + if pkey, ok := m.poolKeys[name]; ok { + m.pool.Release(pkey) + } } } else { // Standalone: close connection directly @@ -398,3 +458,15 @@ func (m *Manager) ServerStatus() []ServerStatus { } return statuses } + +// requireUserCreds checks if an MCP server's settings mandate per-user credentials. +func requireUserCreds(settings json.RawMessage) bool { + if len(settings) == 0 { + return false + } + var s struct { + RequireUserCredentials bool `json:"require_user_credentials"` + } + _ = json.Unmarshal(settings, &s) + return s.RequireUserCredentials +} diff --git a/internal/mcp/manager_connect.go b/internal/mcp/manager_connect.go index 9d4816cf..590cdeb2 100644 --- a/internal/mcp/manager_connect.go +++ b/internal/mcp/manager_connect.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/google/uuid" mcpclient "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" mcpgo "github.com/mark3labs/mcp-go/mcp" @@ -123,8 +124,8 @@ func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, se // connectViaPool acquires a shared connection from the pool and creates // per-agent BridgeTools pointing to the shared client/connected pointers. -func (m *Manager) connectViaPool(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error { - entry, err := m.pool.Acquire(ctx, name, transportType, command, args, env, url, headers, timeoutSec) +func (m *Manager) connectViaPool(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error { + entry, err := m.pool.Acquire(ctx, tenantID, name, transportType, command, args, env, url, headers, timeoutSec) if err != nil { return err } @@ -132,7 +133,9 @@ func (m *Manager) connectViaPool(ctx context.Context, name, transportType, comma // Create per-agent BridgeTools from the pool's shared connection registeredNames := m.registerPoolBridgeTools(entry, name, toolPrefix, timeoutSec) - // Track server state and per-agent tool names + // Track server state and per-agent tool names. + // poolServers/poolToolNames keyed by plain name for Close() iteration. + // poolKeys maps plain name → pool compound key for Release(). m.mu.Lock() m.servers[name] = entry.state if m.poolServers == nil { @@ -143,6 +146,10 @@ func (m *Manager) connectViaPool(ctx context.Context, name, transportType, comma m.poolToolNames = make(map[string][]string) } m.poolToolNames[name] = registeredNames + if m.poolKeys == nil { + m.poolKeys = make(map[string]string) + } + m.poolKeys[name] = poolKey(tenantID, name) m.mu.Unlock() if len(registeredNames) > 0 { diff --git a/internal/mcp/manager_tools.go b/internal/mcp/manager_tools.go index f469abf3..5ba94c1b 100644 --- a/internal/mcp/manager_tools.go +++ b/internal/mcp/manager_tools.go @@ -63,7 +63,9 @@ func (m *Manager) unregisterAllTools() { m.registry.Unregister(toolName) } if m.pool != nil { - m.pool.Release(name) + if pkey, ok := m.poolKeys[name]; ok { + m.pool.Release(pkey) + } } } else { // Standalone: close connection directly diff --git a/internal/mcp/pool.go b/internal/mcp/pool.go index 4786051f..3dc81149 100644 --- a/internal/mcp/pool.go +++ b/internal/mcp/pool.go @@ -2,64 +2,126 @@ package mcp import ( "context" + "fmt" "log/slog" "sync" + "time" + "github.com/google/uuid" mcpgo "github.com/mark3labs/mcp-go/mcp" ) +// PoolConfig configures the MCP connection pool. +type PoolConfig struct { + MaxSize int // global max connections (default 100) + MaxIdle int // max idle connections to keep alive (default 20) + IdleTTL time.Duration // close idle connections after this (default 20m) + AcquireTimeout time.Duration // wait for pool slot before error (default 60s) +} + +// DefaultPoolConfig returns the default pool configuration. +func DefaultPoolConfig() PoolConfig { + return PoolConfig{ + MaxSize: 100, + MaxIdle: 20, + IdleTTL: 20 * time.Minute, + AcquireTimeout: 60 * time.Second, + } +} + // poolEntry holds a shared connection and its discovered tools. type poolEntry struct { state *serverState // connection + health state tools []mcpgo.Tool // discovered MCP tool definitions refCount int // number of active Manager references + lastUsed time.Time // last Acquire/Release time for idle eviction } // Pool manages shared MCP server connections across agents. -// One physical connection per server name, shared by all agents -// that have grants to that server. Each agent creates its own -// BridgeTools pointing to the shared client/connected pointers. +// Connections are keyed by tenantID/serverName for tenant isolation. type Pool struct { mu sync.Mutex servers map[string]*poolEntry + cfg PoolConfig + slot chan struct{} // semaphore for MaxSize + stopCh chan struct{} } -// NewPool creates a shared MCP connection pool. -func NewPool() *Pool { - return &Pool{ - servers: make(map[string]*poolEntry), +// NewPool creates a shared MCP connection pool with idle eviction. +func NewPool(cfg PoolConfig) *Pool { + if cfg.MaxSize <= 0 { + cfg.MaxSize = 100 } + if cfg.MaxIdle <= 0 { + cfg.MaxIdle = 20 + } + if cfg.IdleTTL <= 0 { + cfg.IdleTTL = 20 * time.Minute + } + if cfg.AcquireTimeout <= 0 { + cfg.AcquireTimeout = 60 * time.Second + } + + p := &Pool{ + servers: make(map[string]*poolEntry), + cfg: cfg, + slot: make(chan struct{}, cfg.MaxSize), + stopCh: make(chan struct{}), + } + go p.evictLoop() + return p } -// Acquire returns a shared connection for the named server. -// If no connection exists, it connects using the provided config. -// Increments the reference count. -func (p *Pool) Acquire(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) { - p.mu.Lock() +// poolKey builds a tenant-scoped key for pool lookups. +func poolKey(tenantID uuid.UUID, name string) string { + return tenantID.String() + "/" + name +} - if entry, ok := p.servers[name]; ok && entry.state.connected.Load() { +// Acquire returns a shared connection for the named server scoped to a tenant. +// If no connection exists, it connects using the provided config. +// Blocks up to AcquireTimeout if pool is at MaxSize. +func (p *Pool) Acquire(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) { + key := poolKey(tenantID, name) + + p.mu.Lock() + if entry, ok := p.servers[key]; ok && entry.state.connected.Load() { entry.refCount++ + entry.lastUsed = time.Now() p.mu.Unlock() - slog.Debug("mcp.pool.reuse", "server", name, "refCount", entry.refCount) + slog.Debug("mcp.pool.reuse", "key", key, "refCount", entry.refCount) return entry, nil } - // If entry exists but disconnected, close old connection first - if old, ok := p.servers[name]; ok { + // If entry exists but disconnected, close old and reclaim slot + if old, ok := p.servers[key]; ok { if old.state.cancel != nil { old.state.cancel() } if old.state.client != nil { _ = old.state.client.Close() } - delete(p.servers, name) + delete(p.servers, key) + // Return slot to semaphore + select { + case <-p.slot: + default: + } } - p.mu.Unlock() + // Acquire a slot (blocks if pool full, evicts idle if possible) + if err := p.acquireSlot(ctx); err != nil { + return nil, fmt.Errorf("mcp pool exhausted: %w", err) + } + // Connect outside the lock (may be slow) ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec) if err != nil { + // Return slot on failure + select { + case <-p.slot: + default: + } return nil, err } @@ -72,63 +134,226 @@ func (p *Pool) Acquire(ctx context.Context, name, transportType, command string, state: ss, tools: mcpTools, refCount: 1, + lastUsed: time.Now(), } p.mu.Lock() // Check if another goroutine connected while we were connecting - if existing, ok := p.servers[name]; ok && existing.state.connected.Load() { - // Use existing, close ours + if existing, ok := p.servers[key]; ok && existing.state.connected.Load() { p.mu.Unlock() hcancel() _ = ss.client.Close() + // Return our extra slot + select { + case <-p.slot: + default: + } p.mu.Lock() existing.refCount++ + existing.lastUsed = time.Now() p.mu.Unlock() return existing, nil } - p.servers[name] = entry + p.servers[key] = entry p.mu.Unlock() - slog.Info("mcp.pool.connected", "server", name, "tools", len(mcpTools)) + slog.Info("mcp.pool.connected", "key", key, "tools", len(mcpTools)) return entry, nil } +// acquireSlot tries to acquire a pool slot, evicting idle connections if needed. +func (p *Pool) acquireSlot(ctx context.Context) error { + // Fast path: slot available + select { + case p.slot <- struct{}{}: + return nil + default: + } + + // Try evicting one idle entry + p.mu.Lock() + evicted := p.evictOldestIdleLocked() + p.mu.Unlock() + + if evicted { + select { + case p.slot <- struct{}{}: + return nil + default: + } + } + + // Wait up to AcquireTimeout + timer := time.NewTimer(p.cfg.AcquireTimeout) + defer timer.Stop() + + select { + case p.slot <- struct{}{}: + return nil + case <-timer.C: + return fmt.Errorf("timeout after %s waiting for pool slot (max %d)", p.cfg.AcquireTimeout, p.cfg.MaxSize) + case <-ctx.Done(): + return ctx.Err() + } +} + // Release decrements the reference count for a server. -// The connection is NOT closed when refCount reaches 0 — it stays -// alive for future agents. Use Stop() to close all connections. -func (p *Pool) Release(name string) { +// Accepts the same key format as Acquire (tenantID + name). +func (p *Pool) Release(key string) { p.mu.Lock() defer p.mu.Unlock() - if entry, ok := p.servers[name]; ok { + if entry, ok := p.servers[key]; ok { entry.refCount-- if entry.refCount < 0 { entry.refCount = 0 } - slog.Debug("mcp.pool.release", "server", name, "refCount", entry.refCount) + entry.lastUsed = time.Now() + slog.Debug("mcp.pool.release", "key", key, "refCount", entry.refCount) } } -// Stop closes all pooled connections. Called on gateway shutdown. +// Stop closes all pooled connections and stops eviction. Called on gateway shutdown. func (p *Pool) Stop() { + close(p.stopCh) + p.mu.Lock() defer p.mu.Unlock() - for name, entry := range p.servers { + for key, entry := range p.servers { if entry.state.cancel != nil { entry.state.cancel() } if entry.state.client != nil { _ = entry.state.client.Close() } - slog.Debug("mcp.pool.stopped", "server", name) + slog.Debug("mcp.pool.stopped", "key", key) } p.servers = make(map[string]*poolEntry) } +// Evict closes a specific pooled connection by tenant + server name. +// Called when server credentials are rotated to force reconnection with new credentials. +func (p *Pool) Evict(tenantID uuid.UUID, serverName string) { + key := poolKey(tenantID, serverName) + p.mu.Lock() + defer p.mu.Unlock() + + entry, ok := p.servers[key] + if !ok { + return + } + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.servers, key) + select { + case <-p.slot: + default: + } + slog.Info("mcp.pool.evicted_on_rotation", "key", key) +} + +// evictLoop runs periodically to close idle connections over MaxIdle count. +func (p *Pool) evictLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.evictIdle() + } + } +} + +// evictIdle closes connections idle > IdleTTL when total idle exceeds MaxIdle. +func (p *Pool) evictIdle() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + var idleKeys []string + for key, entry := range p.servers { + if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.IdleTTL { + idleKeys = append(idleKeys, key) + } + } + + // Count total idle (refCount == 0) + totalIdle := 0 + for _, entry := range p.servers { + if entry.refCount == 0 { + totalIdle++ + } + } + + // Only evict if over MaxIdle + toEvict := totalIdle - p.cfg.MaxIdle + if toEvict <= 0 && len(idleKeys) == 0 { + return + } + + // Evict TTL-expired first, then oldest if still over MaxIdle + for _, key := range idleKeys { + entry := p.servers[key] + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.servers, key) + // Return slot + select { + case <-p.slot: + default: + } + slog.Debug("mcp.pool.evicted", "key", key, "reason", "idle_ttl") + } +} + +// evictOldestIdleLocked evicts one idle entry (oldest lastUsed). Caller must hold mu. +func (p *Pool) evictOldestIdleLocked() bool { + var oldestKey string + var oldestTime time.Time + + for key, entry := range p.servers { + if entry.refCount == 0 { + if oldestKey == "" || entry.lastUsed.Before(oldestTime) { + oldestKey = key + oldestTime = entry.lastUsed + } + } + } + + if oldestKey == "" { + return false + } + + entry := p.servers[oldestKey] + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.servers, oldestKey) + // Return slot to semaphore + select { + case <-p.slot: + default: + } + slog.Debug("mcp.pool.evicted", "key", oldestKey, "reason", "make_room") + return true +} + // poolHealthLoop is a standalone health loop for pool-managed connections. -// Unlike Manager.healthLoop, it doesn't trigger reconnection via Manager -// since the pool owns the connection lifecycle. func poolHealthLoop(ctx context.Context, ss *serverState) { ticker := newHealthTicker() defer ticker.Stop() diff --git a/internal/media/store.go b/internal/media/store.go index b6505985..162871b6 100644 --- a/internal/media/store.go +++ b/internal/media/store.go @@ -36,7 +36,7 @@ func (s *Store) SaveFile(sessionKey, srcPath, mime string) (id string, dstPath s } mediaID := uuid.New().String() - ext := extFromMime(mime) + ext := ExtFromMime(mime) if ext == "" { ext = filepath.Ext(srcPath) } @@ -88,8 +88,8 @@ func (s *Store) sessionDir(sessionKey string) string { return filepath.Join(s.baseDir, hash) } -// extFromMime returns a file extension (with dot) for a MIME type. -func extFromMime(mime string) string { +// ExtFromMime returns a file extension (with dot) for a MIME type. +func ExtFromMime(mime string) string { switch { case strings.HasPrefix(mime, "image/jpeg"): return ".jpg" diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 21f9b015..9e9a7ff2 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -36,6 +36,7 @@ type DBTokenSource struct { providerStore store.ProviderStore secretsStore store.ConfigSecretsStore providerName string + tenantID uuid.UUID // tenant context for DB queries mu sync.Mutex cachedToken string @@ -48,9 +49,16 @@ func NewDBTokenSource(provStore store.ProviderStore, secretsStore store.ConfigSe providerStore: provStore, secretsStore: secretsStore, providerName: providerName, + tenantID: store.MasterTenantID, } } +// WithTenantID sets the tenant context for DB queries. Must be called at init time before Token(). +func (ts *DBTokenSource) WithTenantID(tenantID uuid.UUID) *DBTokenSource { + ts.tenantID = tenantID + return ts +} + // Token returns a valid access token, refreshing if expired or about to expire. func (ts *DBTokenSource) Token() (string, error) { ts.mu.Lock() @@ -61,7 +69,7 @@ func (ts *DBTokenSource) Token() (string, error) { return ts.cachedToken, nil } - ctx := context.Background() + ctx := store.WithTenantID(context.Background(), ts.tenantID) // Load from DB if not cached if ts.cachedToken == "" { diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 4bfe6369..0a95cdb8 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -37,6 +37,7 @@ const ( ScopeWrite Scope = "operator.write" ScopeApprovals Scope = "operator.approvals" ScopePairing Scope = "operator.pairing" + ScopeProvision Scope = "operator.provision" ) // AllScopes is the set of all valid API key scopes. @@ -46,6 +47,7 @@ var AllScopes = map[Scope]bool{ ScopeWrite: true, ScopeApprovals: true, ScopePairing: true, + ScopeProvision: true, } // ValidScope reports whether s is a recognised API key scope. diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 9b26ec59..c06b0dc2 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -1,80 +1,164 @@ package providers import ( + "context" "fmt" "io" "log/slog" + "strings" "sync" + + "github.com/google/uuid" ) -// Registry manages available LLM providers. +// MasterTenantID is the default tenant for config-based providers. +var MasterTenantID = uuid.Must(uuid.Parse("0193a5b0-7000-7000-8000-000000000001")) + +// Registry manages available LLM providers with per-tenant isolation. +// Providers are keyed by "tenantID/name". Get falls back to master tenant. type Registry struct { - providers map[string]Provider - mu sync.RWMutex + providers map[string]Provider + mu sync.RWMutex + tenantFromCtx func(context.Context) uuid.UUID // injected to avoid circular import with store } -func NewRegistry() *Registry { +// NewRegistry creates a provider registry. +// tenantFromCtx extracts tenant UUID from context (pass store.TenantIDFromContext). +func NewRegistry(tenantFromCtx func(context.Context) uuid.UUID) *Registry { return &Registry{ - providers: make(map[string]Provider), + providers: make(map[string]Provider), + tenantFromCtx: tenantFromCtx, } } -// Register adds a provider to the registry. -// If a provider with the same name already exists, it is closed before replacement. +// compoundKey returns "tenantID/name" for registry lookup. +func compoundKey(tenantID uuid.UUID, name string) string { + return tenantID.String() + "/" + name +} + +// tenantFromContext resolves the tenant UUID from context, defaulting to MasterTenantID. +func (r *Registry) tenantFromContext(ctx context.Context) uuid.UUID { + if r.tenantFromCtx != nil { + if t := r.tenantFromCtx(ctx); t != uuid.Nil { + return t + } + } + return MasterTenantID +} + +// Register adds a provider to the registry under the master tenant. func (r *Registry) Register(provider Provider) { + r.RegisterForTenant(MasterTenantID, provider) +} + +// RegisterForTenant adds a provider under a specific tenant. +// If a provider with the same tenant+name already exists, it is closed before replacement. +func (r *Registry) RegisterForTenant(tenantID uuid.UUID, provider Provider) { r.mu.Lock() defer r.mu.Unlock() - if old, ok := r.providers[provider.Name()]; ok { + key := compoundKey(tenantID, provider.Name()) + if old, ok := r.providers[key]; ok { if c, ok := old.(io.Closer); ok { c.Close() } } - r.providers[provider.Name()] = provider + r.providers[key] = provider } -// Unregister removes a provider from the registry, closing it if it implements io.Closer. +// Unregister removes a provider from the master tenant. func (r *Registry) Unregister(name string) { + r.UnregisterForTenant(MasterTenantID, name) +} + +// UnregisterForTenant removes a provider from a specific tenant. +func (r *Registry) UnregisterForTenant(tenantID uuid.UUID, name string) { r.mu.Lock() defer r.mu.Unlock() - if old, ok := r.providers[name]; ok { + key := compoundKey(tenantID, name) + if old, ok := r.providers[key]; ok { if c, ok := old.(io.Closer); ok { c.Close() } - delete(r.providers, name) + delete(r.providers, key) } } -// Get returns a provider by name. -func (r *Registry) Get(name string) (Provider, error) { +// Get returns a provider by name, using tenant from context (falls back to master). +func (r *Registry) Get(ctx context.Context, name string) (Provider, error) { + return r.GetForTenant(r.tenantFromContext(ctx), name) +} + +// GetForTenant returns a provider by name for a specific tenant. +// Falls back to master tenant if not found for the given tenant. +func (r *Registry) GetForTenant(tenantID uuid.UUID, name string) (Provider, error) { r.mu.RLock() defer r.mu.RUnlock() - p, ok := r.providers[name] - if !ok { - return nil, fmt.Errorf("provider not found: %s", name) + + // Try tenant-specific first + if tenantID != MasterTenantID { + if p, ok := r.providers[compoundKey(tenantID, name)]; ok { + return p, nil + } } - return p, nil + // Fallback to master tenant + if p, ok := r.providers[compoundKey(MasterTenantID, name)]; ok { + return p, nil + } + return nil, fmt.Errorf("provider not found: %s", name) } // Close calls Close() on all providers that implement io.Closer. func (r *Registry) Close() { - r.mu.RLock() - defer r.mu.RUnlock() - for name, p := range r.providers { + r.mu.Lock() + defer r.mu.Unlock() + for key, p := range r.providers { if c, ok := p.(io.Closer); ok { if err := c.Close(); err != nil { - slog.Warn("provider close error", "name", name, "error", err) + slog.Warn("provider close error", "key", key, "error", err) } } } } -// List returns all registered provider names. -func (r *Registry) List() []string { +// List returns provider names visible to the tenant in context (tenant-specific + master defaults). +func (r *Registry) List(ctx context.Context) []string { + return r.ListForTenant(r.tenantFromContext(ctx)) +} + +// ListForTenant returns provider names available to a tenant (tenant-specific + master defaults). +func (r *Registry) ListForTenant(tenantID uuid.UUID) []string { r.mu.RLock() defer r.mu.RUnlock() - names := make([]string, 0, len(r.providers)) - for name := range r.providers { - names = append(names, name) + + seen := make(map[string]bool) + var names []string + + masterPrefix := MasterTenantID.String() + "/" + tenantPrefix := tenantID.String() + "/" + + // Add tenant-specific providers first + if tenantID != MasterTenantID { + for key := range r.providers { + if strings.HasPrefix(key, tenantPrefix) { + name := strings.TrimPrefix(key, tenantPrefix) + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + } } + + // Add master tenant providers (not already overridden) + for key := range r.providers { + if strings.HasPrefix(key, masterPrefix) { + name := strings.TrimPrefix(key, masterPrefix) + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + } + return names } diff --git a/internal/providers/types.go b/internal/providers/types.go index a0cb1ff3..0b3d204d 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -85,9 +85,10 @@ type ImageContent struct { // Stored in session JSONB (~60 bytes each) instead of megabytes for base64. // On reload, MediaRefs are resolved to file paths and loaded into Images (for images). type MediaRef struct { - ID string `json:"id"` // unique media ID (uuid) - MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf" - Kind string `json:"kind"` // "image", "video", "audio", "document" + ID string `json:"id"` // unique media ID (uuid) + MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf" + Kind string `json:"kind"` // "image", "video", "audio", "document" + Path string `json:"path,omitempty"` // absolute workspace path (persisted for /v1/files/ serving) } // Message represents a conversation message. diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 14ef8f19..f54311a5 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -1,6 +1,7 @@ package sessions import ( + "context" "encoding/json" "fmt" "os" @@ -63,7 +64,7 @@ func SessionKey(agentID, scopeKey string) string { } // GetOrCreate returns an existing session or creates a new one. -func (m *Manager) GetOrCreate(key string) *Session { +func (m *Manager) GetOrCreate(_ context.Context, key string) *Session { m.mu.Lock() defer m.mu.Unlock() @@ -82,7 +83,7 @@ func (m *Manager) GetOrCreate(key string) *Session { } // AddMessage appends a message to a session. -func (m *Manager) AddMessage(key string, msg providers.Message) { +func (m *Manager) AddMessage(_ context.Context, key string, msg providers.Message) { m.mu.Lock() defer m.mu.Unlock() @@ -101,7 +102,7 @@ func (m *Manager) AddMessage(key string, msg providers.Message) { } // GetHistory returns a copy of the message history. -func (m *Manager) GetHistory(key string) []providers.Message { +func (m *Manager) GetHistory(_ context.Context, key string) []providers.Message { m.mu.RLock() defer m.mu.RUnlock() @@ -116,7 +117,7 @@ func (m *Manager) GetHistory(key string) []providers.Message { } // GetSummary returns the session summary. -func (m *Manager) GetSummary(key string) string { +func (m *Manager) GetSummary(_ context.Context, key string) string { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -126,7 +127,7 @@ func (m *Manager) GetSummary(key string) string { } // SetSummary updates the session summary. -func (m *Manager) SetSummary(key, summary string) { +func (m *Manager) SetSummary(_ context.Context, key, summary string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -136,7 +137,7 @@ func (m *Manager) SetSummary(key, summary string) { } // SetLabel updates the session label. -func (m *Manager) SetLabel(key, label string) { +func (m *Manager) SetLabel(_ context.Context, key, label string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -146,7 +147,7 @@ func (m *Manager) SetLabel(key, label string) { } // UpdateMetadata sets model/provider/channel metadata on a session. -func (m *Manager) UpdateMetadata(key, model, provider, channel string) { +func (m *Manager) UpdateMetadata(_ context.Context, key, model, provider, channel string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -163,7 +164,7 @@ func (m *Manager) UpdateMetadata(key, model, provider, channel string) { } // AccumulateTokens adds token counts from a completed run. -func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64) { +func (m *Manager) AccumulateTokens(_ context.Context, key string, inputTokens, outputTokens int64) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -173,7 +174,7 @@ func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64) } // IncrementCompaction bumps the compaction counter after summarization. -func (m *Manager) IncrementCompaction(key string) { +func (m *Manager) IncrementCompaction(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -182,7 +183,7 @@ func (m *Manager) IncrementCompaction(key string) { } // GetCompactionCount returns the current compaction count for a session. -func (m *Manager) GetCompactionCount(key string) int { +func (m *Manager) GetCompactionCount(_ context.Context, key string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -192,7 +193,7 @@ func (m *Manager) GetCompactionCount(key string) int { } // GetMemoryFlushCompactionCount returns the compaction count at which memory flush last ran. -func (m *Manager) GetMemoryFlushCompactionCount(key string) int { +func (m *Manager) GetMemoryFlushCompactionCount(_ context.Context, key string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -202,7 +203,7 @@ func (m *Manager) GetMemoryFlushCompactionCount(key string) int { } // SetMemoryFlushDone records that memory flush completed at the current compaction count. -func (m *Manager) SetMemoryFlushDone(key string) { +func (m *Manager) SetMemoryFlushDone(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -212,7 +213,7 @@ func (m *Manager) SetMemoryFlushDone(key string) { } // SetSpawnInfo sets subagent origin metadata on a session. -func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) { +func (m *Manager) SetSpawnInfo(_ context.Context, key, spawnedBy string, depth int) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -222,7 +223,7 @@ func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) { } // SetContextWindow caches the agent's context window on the session. -func (m *Manager) SetContextWindow(key string, cw int) { +func (m *Manager) SetContextWindow(_ context.Context, key string, cw int) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -231,7 +232,7 @@ func (m *Manager) SetContextWindow(key string, cw int) { } // GetContextWindow returns the cached context window for a session (0 if unset). -func (m *Manager) GetContextWindow(key string) int { +func (m *Manager) GetContextWindow(_ context.Context, key string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -241,7 +242,7 @@ func (m *Manager) GetContextWindow(key string) int { } // SetLastPromptTokens records actual prompt tokens from the last LLM response. -func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) { +func (m *Manager) SetLastPromptTokens(_ context.Context, key string, tokens, msgCount int) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -251,7 +252,7 @@ func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) { } // GetLastPromptTokens returns the last known prompt tokens and message count. -func (m *Manager) GetLastPromptTokens(key string) (int, int) { +func (m *Manager) GetLastPromptTokens(_ context.Context, key string) (int, int) { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -261,7 +262,7 @@ func (m *Manager) GetLastPromptTokens(key string) (int, int) { } // TruncateHistory keeps only the last N messages. -func (m *Manager) TruncateHistory(key string, keepLast int) { +func (m *Manager) TruncateHistory(_ context.Context, key string, keepLast int) { m.mu.Lock() defer m.mu.Unlock() @@ -279,7 +280,7 @@ func (m *Manager) TruncateHistory(key string, keepLast int) { } // SetHistory replaces a session's message history with the given slice. -func (m *Manager) SetHistory(key string, msgs []providers.Message) { +func (m *Manager) SetHistory(_ context.Context, key string, msgs []providers.Message) { m.mu.Lock() defer m.mu.Unlock() @@ -290,7 +291,7 @@ func (m *Manager) SetHistory(key string, msgs []providers.Message) { } // Reset clears a session's history and summary. -func (m *Manager) Reset(key string) { +func (m *Manager) Reset(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() @@ -302,7 +303,7 @@ func (m *Manager) Reset(key string) { } // Delete removes a session entirely. -func (m *Manager) Delete(key string) error { +func (m *Manager) Delete(_ context.Context, key string) error { m.mu.Lock() delete(m.sessions, key) m.mu.Unlock() @@ -318,7 +319,7 @@ func (m *Manager) Delete(key string) error { } // List returns metadata for all sessions, optionally filtered by agent ID. -func (m *Manager) List(agentID string) []SessionInfo { +func (m *Manager) List(_ context.Context, agentID string) []SessionInfo { m.mu.RLock() defer m.mu.RUnlock() @@ -346,7 +347,7 @@ func (m *Manager) List(agentID string) []SessionInfo { // LastUsedChannel finds the most recently updated channel session for an agent // and extracts channel + chatID from the key. Returns ("", "") if none found. -func (m *Manager) LastUsedChannel(agentID string) (channel, chatID string) { +func (m *Manager) LastUsedChannel(_ context.Context, agentID string) (channel, chatID string) { m.mu.RLock() defer m.mu.RUnlock() @@ -392,7 +393,7 @@ type SessionInfo struct { } // Save persists a session to disk atomically. -func (m *Manager) Save(key string) error { +func (m *Manager) Save(_ context.Context, key string) error { if m.storage == "" { return nil } diff --git a/internal/skills/dep_scanner.go b/internal/skills/dep_scanner.go index adcb2b54..b61c7ebb 100644 --- a/internal/skills/dep_scanner.go +++ b/internal/skills/dep_scanner.go @@ -1,6 +1,7 @@ package skills import ( + "log/slog" "os" "path/filepath" "regexp" @@ -34,6 +35,7 @@ func scanScriptsDir(scriptsDir string) *SkillManifest { entries, err := os.ReadDir(scriptsDir) if err != nil { + slog.Debug("dep_scanner: scripts dir not found", "dir", scriptsDir, "error", err) return m } @@ -104,6 +106,11 @@ func scanScriptsDir(scriptsDir string) *SkillManifest { m.Requires = append(m.Requires, "node") } + if !m.IsEmpty() { + slog.Debug("dep_scanner: scanned", "dir", scriptsDir, + "bins", len(m.Requires), "py", len(m.RequiresPython), "node", len(m.RequiresNode)) + } + return m } diff --git a/internal/skills/loader.go b/internal/skills/loader.go index 9b43c18a..682546ca 100644 --- a/internal/skills/loader.go +++ b/internal/skills/loader.go @@ -10,6 +10,7 @@ package skills import ( + "context" "encoding/json" "fmt" "os" @@ -100,7 +101,7 @@ func (l *Loader) SetManagedDir(dir string) { // ListSkills returns all available skills, respecting the priority hierarchy. // Higher-priority sources override lower ones by name. -func (l *Loader) ListSkills() []Info { +func (l *Loader) ListSkills(_ context.Context) []Info { l.mu.Lock() defer l.mu.Unlock() @@ -278,7 +279,7 @@ func (l *Loader) findLatestVersion(slug string) (int, string) { // LoadSkill reads and returns the content of a skill by name (frontmatter stripped). // The {baseDir} placeholder in SKILL.md is replaced with the skill's absolute directory path. // Priority: workspace > agents > global > managed > builtin -func (l *Loader) LoadSkill(name string) (string, bool) { +func (l *Loader) LoadSkill(_ context.Context, name string) (string, bool) { // Check flat skill directories (workspace, agents, global) first for _, dir := range []string{l.workspaceSkills, l.projectAgentSkills, l.personalAgentSkills, l.globalSkills} { if dir == "" { @@ -324,12 +325,12 @@ func (l *Loader) LoadSkill(name string) (string, bool) { // LoadForContext loads multiple skills and formats them for system prompt injection. // If allowList is nil, all skills are loaded. If non-nil, only listed skills are loaded. -func (l *Loader) LoadForContext(allowList []string) string { +func (l *Loader) LoadForContext(ctx context.Context, allowList []string) string { var names []string if allowList == nil { // Load all available skills - for _, s := range l.ListSkills() { + for _, s := range l.ListSkills(ctx) { names = append(names, s.Name) } } else { @@ -342,7 +343,7 @@ func (l *Loader) LoadForContext(allowList []string) string { var parts []string for _, name := range names { - content, ok := l.LoadSkill(name) + content, ok := l.LoadSkill(ctx, name) if !ok { continue } @@ -359,8 +360,8 @@ func (l *Loader) LoadForContext(allowList []string) string { // BuildSummary returns an XML summary of skills for context injection. // If allowList is nil, all skills are included. If non-nil, only listed skills are included. // The format matches the TS XML used in system prompts. -func (l *Loader) BuildSummary(allowList []string) string { - allSkills := l.ListSkills() +func (l *Loader) BuildSummary(ctx context.Context, allowList []string) string { + allSkills := l.ListSkills(ctx) if len(allSkills) == 0 { return "" } @@ -423,8 +424,8 @@ func (l *Loader) Dirs() []string { // FilterSkills returns skills filtered by an allowlist. // If allowList is nil, all skills are returned. If empty slice, none are returned. -func (l *Loader) FilterSkills(allowList []string) []Info { - all := l.ListSkills() +func (l *Loader) FilterSkills(ctx context.Context, allowList []string) []Info { + all := l.ListSkills(ctx) if allowList == nil { return all } @@ -445,9 +446,9 @@ func (l *Loader) FilterSkills(allowList []string) []Info { } // GetSkill returns info about a specific skill. -func (l *Loader) GetSkill(name string) (*Info, bool) { +func (l *Loader) GetSkill(ctx context.Context, name string) (*Info, bool) { // Ensure cache is populated - l.ListSkills() + l.ListSkills(ctx) l.mu.RLock() defer l.mu.RUnlock() diff --git a/internal/skills/seeder.go b/internal/skills/seeder.go index a662282e..3c07975b 100644 --- a/internal/skills/seeder.go +++ b/internal/skills/seeder.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -20,7 +21,7 @@ type SystemSkillStore interface { UpsertSystemSkill(ctx context.Context, p pg.SkillCreateParams) (uuid.UUID, bool, string, error) GetNextVersion(slug string) int BumpVersion() - UpdateSkill(id uuid.UUID, updates map[string]interface{}) error + UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]interface{}) error StoreMissingDeps(id uuid.UUID, missing []string) error } @@ -130,7 +131,7 @@ func (s *Seeder) Seed(ctx context.Context) (seeded int, skipped int, skills []se // scripts/ dir has content but the managed scripts/ dir is missing or empty. if needsReCopy(skillDir, actualDir) { slog.Info("seeder: managed dir incomplete, re-copying", "slug", slug, "dir", actualDir) - if err := copyDir(skillDir, actualDir); err != nil { + if err := CopyDir(skillDir, actualDir); err != nil { slog.Error("seeder: failed to re-copy skill files", "slug", slug, "error", err) } } @@ -140,7 +141,7 @@ func (s *Seeder) Seed(ctx context.Context) (seeded int, skipped int, skills []se } // Copy skill directory to managed dir - if err := copyDir(skillDir, destDir); err != nil { + if err := CopyDir(skillDir, destDir); err != nil { slog.Error("seeder: failed to copy skill files", "slug", slug, "error", err) continue } @@ -176,7 +177,7 @@ func (s *Seeder) CheckDepsAsync(skills []seededSkill, msgBus *bus.MessageBus) { status := "active" if !ok { status = "archived" - _ = s.store.UpdateSkill(sk.id, map[string]interface{}{"status": "archived"}) + _ = s.store.UpdateSkill(store.WithCrossTenant(context.Background()), sk.id, map[string]interface{}{"status": "archived"}) s.store.BumpVersion() slog.Warn("seeder: skill deps missing", "slug", sk.slug, "missing", FormatMissing(missing)) } @@ -230,7 +231,7 @@ func (s *Seeder) copySharedDir(name string) { return } - if err := copyDir(src, dst); err != nil { + if err := CopyDir(src, dst); err != nil { slog.Warn("seeder: failed to copy shared dir", "name", name, "error", err) } } @@ -239,7 +240,8 @@ func (s *Seeder) copySharedDir(name string) { // Resolves the top-level path and any mid-tree symlinks pointing to directories // so local module symlinks (e.g. scripts/office -> ../../_shared/office) // are copied as real directories rather than left as dangling entries. -func copyDir(src, dst string) error { +// CopyDir recursively copies a directory tree. +func CopyDir(src, dst string) error { resolved, err := filepath.EvalSymlinks(src) if err != nil { resolved = src @@ -264,7 +266,7 @@ func copyDir(src, dst string) error { // Detect and recurse into directory symlinks so local modules are fully copied. if info.Mode()&os.ModeSymlink != 0 { if realInfo, statErr := os.Stat(path); statErr == nil && realInfo.IsDir() { - return copyDir(path, target) + return CopyDir(path, target) } return nil // skip broken symlinks } diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 52b0bf98..3b5e542b 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -41,7 +41,8 @@ const ( // AgentData represents an agent in the database. type AgentData struct { BaseModel - AgentKey string `json:"agent_key"` + TenantID uuid.UUID `json:"tenant_id"` + AgentKey string `json:"agent_key"` DisplayName string `json:"display_name,omitempty"` Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt) OwnerID string `json:"owner_id"` @@ -315,6 +316,8 @@ type AgentStore interface { Create(ctx context.Context, agent *AgentData) error GetByKey(ctx context.Context, agentKey string) (*AgentData, error) GetByID(ctx context.Context, id uuid.UUID) (*AgentData, error) + GetByKeys(ctx context.Context, keys []string) ([]AgentData, error) + GetByIDs(ctx context.Context, ids []uuid.UUID) ([]AgentData, error) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, ownerID string) ([]AgentData, error) diff --git a/internal/store/api_key_store.go b/internal/store/api_key_store.go index baaf08dc..af8b245b 100644 --- a/internal/store/api_key_store.go +++ b/internal/store/api_key_store.go @@ -10,11 +10,13 @@ import ( // APIKeyData represents a gateway API key with scoped permissions. type APIKeyData struct { ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` // uuid.Nil when NULL in DB Name string `json:"name"` - Prefix string `json:"prefix"` // first 8 chars for display - KeyHash string `json:"-"` // SHA-256 hex, never serialized - Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"] - ExpiresAt *time.Time `json:"expires_at"` // nil = never + Prefix string `json:"prefix"` // first 8 chars for display + KeyHash string `json:"-"` // SHA-256 hex, never serialized + Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"] + OwnerID string `json:"owner_id,omitempty"` // bound user; when set, auth forces user_id = owner_id + ExpiresAt *time.Time `json:"expires_at"` // nil = never LastUsedAt *time.Time `json:"last_used_at"` Revoked bool `json:"revoked"` CreatedBy string `json:"created_by"` @@ -30,14 +32,14 @@ type APIKeyStore interface { // GetByHash looks up an active (non-revoked, non-expired) key by its SHA-256 hash. GetByHash(ctx context.Context, keyHash string) (*APIKeyData, error) - // List returns all API keys (for admin display; key hashes are not included). - List(ctx context.Context) ([]APIKeyData, error) + // List returns API keys. If ownerID is non-empty, filters to keys owned by that user. + List(ctx context.Context, ownerID string) ([]APIKeyData, error) - // Revoke marks a key as revoked. - Revoke(ctx context.Context, id uuid.UUID) error + // Revoke marks a key as revoked. If ownerID is non-empty, also enforces owner_id = ownerID. + Revoke(ctx context.Context, id uuid.UUID, ownerID string) error - // Delete permanently removes a key. - Delete(ctx context.Context, id uuid.UUID) error + // Delete permanently removes a key. If ownerID is non-empty, also enforces owner_id = ownerID. + Delete(ctx context.Context, id uuid.UUID, ownerID string) error // TouchLastUsed updates the last_used_at timestamp. TouchLastUsed(ctx context.Context, id uuid.UUID) error diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 50b975ab..7e1d6acd 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -11,6 +11,7 @@ import ( // ChannelInstanceData represents a channel instance in the database. type ChannelInstanceData struct { BaseModel + TenantID uuid.UUID `json:"tenant_id,omitempty"` Name string `json:"name"` DisplayName string `json:"display_name"` ChannelType string `json:"channel_type"` diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go index 0cc9e9b9..4715b7b8 100644 --- a/internal/store/contact_store.go +++ b/internal/store/contact_store.go @@ -49,7 +49,18 @@ type ContactStore interface { // Returns a map of sender_id → ChannelContact (first match per sender_id). GetContactsBySenderIDs(ctx context.Context, senderIDs []string) (map[string]ChannelContact, error) - // MergeContacts assigns the same merged_id to all given contact IDs, - // linking them as the same person across channels. - MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error + // GetContactByID returns a single contact by primary key. Tenant-scoped via context. + GetContactByID(ctx context.Context, id uuid.UUID) (*ChannelContact, error) + + // MergeContacts sets merged_id = tenantUserID on all given contact IDs, + // linking them to a tenant_users identity. Tenant-scoped via context. + MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error + + // UnmergeContacts sets merged_id = NULL on the given contact IDs. + // Tenant-scoped via context. + UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error + + // GetContactsByMergedID returns all contacts linked to a given merged_id. + // Tenant-scoped via context. + GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]ChannelContact, error) } diff --git a/internal/store/context.go b/internal/store/context.go index dae3e9d3..86a3a068 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -29,6 +29,12 @@ const ( SharedKGKey contextKey = "goclaw_shared_kg" // ShellDenyGroupsKey holds per-agent shell deny group overrides. ShellDenyGroupsKey contextKey = "goclaw_shell_deny_groups" + // TenantIDKey is the context key for the tenant UUID. + TenantIDKey contextKey = "goclaw_tenant_id" + // CrossTenantKey indicates the caller has cross-tenant access (owner/system admin). + CrossTenantKey contextKey = "goclaw_cross_tenant" + // TenantSlugKey stores the tenant's URL-safe slug for filesystem paths. + TenantSlugKey contextKey = "goclaw_tenant_slug" ) // WithShellDenyGroups returns a new context with shell deny group overrides. @@ -159,3 +165,42 @@ func LocaleFromContext(ctx context.Context) string { } return "en" } + +// WithTenantID returns a new context with the given tenant UUID. +func WithTenantID(ctx context.Context, id uuid.UUID) context.Context { + return context.WithValue(ctx, TenantIDKey, id) +} + +// TenantIDFromContext extracts the tenant UUID from context. +// Returns uuid.Nil if not set (fail-closed — callers must check). +func TenantIDFromContext(ctx context.Context) uuid.UUID { + if v, ok := ctx.Value(TenantIDKey).(uuid.UUID); ok { + return v + } + return uuid.Nil +} + +// WithCrossTenant returns a context flagged for cross-tenant access. +// Used by owner/system admin callers who can access all tenants. +func WithCrossTenant(ctx context.Context) context.Context { + return context.WithValue(ctx, CrossTenantKey, true) +} + +// IsCrossTenant returns true if the caller has cross-tenant access. +func IsCrossTenant(ctx context.Context) bool { + v, _ := ctx.Value(CrossTenantKey).(bool) + return v +} + +// WithTenantSlug returns a new context with the given tenant slug. +func WithTenantSlug(ctx context.Context, slug string) context.Context { + return context.WithValue(ctx, TenantSlugKey, slug) +} + +// TenantSlugFromContext extracts the tenant slug from context. Returns "" if not set. +func TenantSlugFromContext(ctx context.Context) string { + if v, ok := ctx.Value(TenantSlugKey).(string); ok { + return v + } + return "" +} diff --git a/internal/store/cron_store.go b/internal/store/cron_store.go index 29e942f9..a5e4bb24 100644 --- a/internal/store/cron_store.go +++ b/internal/store/cron_store.go @@ -1,10 +1,16 @@ package store -import "time" +import ( + "context" + "time" + + "github.com/google/uuid" +) // CronJob represents a scheduled job. type CronJob struct { ID string `json:"id"` + TenantID uuid.UUID `json:"tenantId,omitempty"` Name string `json:"name"` AgentID string `json:"agentId,omitempty"` UserID string `json:"userId,omitempty"` @@ -84,19 +90,20 @@ type CronEvent struct { Action string `json:"action"` // "running", "completed", "error" JobID string `json:"jobId"` JobName string `json:"jobName,omitempty"` + UserID string `json:"userId,omitempty"` // job owner for event filtering Status string `json:"status,omitempty"` // final status for completed/error Error string `json:"error,omitempty"` } // CronStore manages scheduled jobs. type CronStore interface { - AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error) - GetJob(jobID string) (*CronJob, bool) - ListJobs(includeDisabled bool, agentID, userID string) []CronJob - RemoveJob(jobID string) error - UpdateJob(jobID string, patch CronJobPatch) (*CronJob, error) - EnableJob(jobID string, enabled bool) error - GetRunLog(jobID string, limit, offset int) ([]CronRunLogEntry, int) + AddJob(ctx context.Context, name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error) + GetJob(ctx context.Context, jobID string) (*CronJob, bool) + ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []CronJob + RemoveJob(ctx context.Context, jobID string) error + UpdateJob(ctx context.Context, jobID string, patch CronJobPatch) (*CronJob, error) + EnableJob(ctx context.Context, jobID string, enabled bool) error + GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]CronRunLogEntry, int) Status() map[string]any // Lifecycle @@ -106,7 +113,7 @@ type CronStore interface { // Job execution SetOnJob(handler func(job *CronJob) (*CronJobResult, error)) SetOnEvent(handler func(event CronEvent)) - RunJob(jobID string, force bool) (ran bool, reason string, err error) + RunJob(ctx context.Context, jobID string, force bool) (ran bool, reason string, err error) SetDefaultTimezone(tz string) // Due job detection (for scheduler) diff --git a/internal/store/custom_tool_store.go b/internal/store/custom_tool_store.go deleted file mode 100644 index eb054198..00000000 --- a/internal/store/custom_tool_store.go +++ /dev/null @@ -1,44 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - - "github.com/google/uuid" -) - -// CustomToolDef represents a custom tool definition in the database. -type CustomToolDef struct { - BaseModel - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` - Command string `json:"command"` - WorkingDir string `json:"working_dir,omitempty"` - TimeoutSeconds int `json:"timeout_seconds"` - Env []byte `json:"-"` // encrypted JSONB — never serialized to API - AgentID *uuid.UUID `json:"agent_id,omitempty"` - Enabled bool `json:"enabled"` - CreatedBy string `json:"created_by"` -} - -// CustomToolListOpts configures custom tool listing with optional pagination and filtering. -type CustomToolListOpts struct { - AgentID *uuid.UUID - Search string - Limit int - Offset int -} - -// CustomToolStore manages custom tool definitions. -type CustomToolStore interface { - Create(ctx context.Context, def *CustomToolDef) error - Get(ctx context.Context, id uuid.UUID) (*CustomToolDef, error) - Update(ctx context.Context, id uuid.UUID, updates map[string]any) error - Delete(ctx context.Context, id uuid.UUID) error - ListGlobal(ctx context.Context) ([]CustomToolDef, error) - ListByAgent(ctx context.Context, agentID uuid.UUID) ([]CustomToolDef, error) - ListAll(ctx context.Context) ([]CustomToolDef, error) - ListPaged(ctx context.Context, opts CustomToolListOpts) ([]CustomToolDef, error) - CountTools(ctx context.Context, opts CustomToolListOpts) (int, error) -} diff --git a/internal/store/mcp_store.go b/internal/store/mcp_store.go index bd673a33..f736f2f5 100644 --- a/internal/store/mcp_store.go +++ b/internal/store/mcp_store.go @@ -76,6 +76,13 @@ type MCPAccessInfo struct { ToolDeny []string `json:"tool_deny,omitempty"` // effective deny list } +// MCPUserCredentials holds per-user credential overrides for an MCP server. +type MCPUserCredentials struct { + APIKey string `json:"api_key,omitempty"` // decrypted + Headers map[string]string `json:"headers,omitempty"` // decrypted + Env map[string]string `json:"env,omitempty"` // decrypted +} + // MCPServerStore manages MCP server configs and access grants. type MCPServerStore interface { // Server CRUD @@ -106,4 +113,9 @@ type MCPServerStore interface { CreateRequest(ctx context.Context, req *MCPAccessRequest) error ListPendingRequests(ctx context.Context) ([]MCPAccessRequest, error) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error + + // Per-user credentials + GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*MCPUserCredentials, error) + SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds MCPUserCredentials) error + DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error } diff --git a/internal/store/pairing_store.go b/internal/store/pairing_store.go index 6f824628..2427b4ad 100644 --- a/internal/store/pairing_store.go +++ b/internal/store/pairing_store.go @@ -1,5 +1,7 @@ package store +import "context" + // PairingRequest represents a pending pairing code. type PairingRequestData struct { Code string `json:"code"` @@ -24,11 +26,11 @@ type PairedDeviceData struct { // PairingStore manages device pairing. type PairingStore interface { - RequestPairing(senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) - ApprovePairing(code, approvedBy string) (*PairedDeviceData, error) - DenyPairing(code string) error - RevokePairing(senderID, channel string) error - IsPaired(senderID, channel string) (bool, error) - ListPending() []PairingRequestData - ListPaired() []PairedDeviceData + RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) + ApprovePairing(ctx context.Context, code, approvedBy string) (*PairedDeviceData, error) + DenyPairing(ctx context.Context, code string) error + RevokePairing(ctx context.Context, senderID, channel string) error + IsPaired(ctx context.Context, senderID, channel string) (bool, error) + ListPending(ctx context.Context) []PairingRequestData + ListPaired(ctx context.Context) []PairedDeviceData } diff --git a/internal/store/pg/activity_store.go b/internal/store/pg/activity_store.go index bbb3cc0d..05d9e584 100644 --- a/internal/store/pg/activity_store.go +++ b/internal/store/pg/activity_store.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -20,17 +22,21 @@ func NewPGActivityStore(db *sql.DB) *PGActivityStore { } func (s *PGActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, - `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, entry.ActorType, entry.ActorID, entry.Action, - entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, + entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, tenantID, ) return err } func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) { - where, args := buildActivityWhere(opts) + where, args := buildActivityWhere(ctx, opts) limit := opts.Limit if limit <= 0 { limit = 50 @@ -61,7 +67,7 @@ func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) } func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) { - where, args := buildActivityWhere(opts) + where, args := buildActivityWhere(ctx, opts) query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where) var count int @@ -69,11 +75,20 @@ func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts return count, err } -func buildActivityWhere(opts store.ActivityListOpts) (string, []any) { +func buildActivityWhere(ctx context.Context, opts store.ActivityListOpts) (string, []any) { var conditions []string var args []any idx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", idx)) + args = append(args, tenantID) + idx++ + } + } + if opts.ActorType != "" { conditions = append(conditions, fmt.Sprintf("actor_type = $%d", idx)) args = append(args, opts.ActorType) diff --git a/internal/store/pg/agent_links.go b/internal/store/pg/agent_links.go index e08d9bfa..1614b3bc 100644 --- a/internal/store/pg/agent_links.go +++ b/internal/store/pg/agent_links.go @@ -55,18 +55,31 @@ func (s *PGAgentLinkStore) CreateLink(ctx context.Context, link *store.AgentLink settings = json.RawMessage(`{}`) } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO agent_links (id, source_agent_id, target_agent_id, direction, team_id, description, - max_concurrent, settings, status, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + max_concurrent, settings, status, created_by, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, link.ID, link.SourceAgentID, link.TargetAgentID, link.Direction, link.TeamID, link.Description, - link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now, + link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now, tenantID, ) return err } func (s *PGAgentLinkStore) DeleteLink(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for delete") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1 AND tenant_id = $2`, id, tid) return err } @@ -75,16 +88,33 @@ func (s *PGAgentLinkStore) UpdateLink(ctx context.Context, id uuid.UUID, updates return nil } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "agent_links", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "agent_links", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "agent_links", updates, id, tid) } func (s *PGAgentLinkStore) GetLink(ctx context.Context, id uuid.UUID) (*store.AgentLinkData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id) + return scanLinkRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, fmt.Errorf("link not found: %w", sql.ErrNoRows) + } row := s.db.QueryRowContext(ctx, - `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id) + `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1 AND tenant_id = $2`, id, tenantID) return scanLinkRow(row) } func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) { + tenantClause, qArgs := linkTenantClause(ctx, agentID, "l.source_agent_id = $1") rows, err := s.db.QueryContext(ctx, `SELECT `+linkSelectColsJoined+`, sa.agent_key AS source_agent_key, @@ -98,8 +128,8 @@ func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) JOIN agents sa ON sa.id = l.source_agent_id JOIN agents ta ON ta.id = l.target_agent_id LEFT JOIN agent_teams tm ON tm.id = l.team_id - WHERE l.source_agent_id = $1 - ORDER BY l.created_at`, agentID) + WHERE `+tenantClause+` + ORDER BY l.created_at`, qArgs...) if err != nil { return nil, err } @@ -108,6 +138,7 @@ func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) } func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) { + tenantClause, qArgs := linkTenantClause(ctx, agentID, "l.target_agent_id = $1") rows, err := s.db.QueryContext(ctx, `SELECT `+linkSelectColsJoined+`, sa.agent_key AS source_agent_key, @@ -121,8 +152,8 @@ func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ( JOIN agents sa ON sa.id = l.source_agent_id JOIN agents ta ON ta.id = l.target_agent_id LEFT JOIN agent_teams tm ON tm.id = l.team_id - WHERE l.target_agent_id = $1 - ORDER BY l.created_at`, agentID) + WHERE `+tenantClause+` + ORDER BY l.created_at`, qArgs...) if err != nil { return nil, err } @@ -130,6 +161,22 @@ func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ( return scanLinkRowsJoined(rows) } +// linkTenantClause builds the WHERE clause for agent_links queries. +// baseCondition must use $1 for the agentID parameter. +// Returns the full WHERE clause and args slice. +func linkTenantClause(ctx context.Context, agentID uuid.UUID, baseCondition string) (string, []any) { + args := []any{agentID} + if store.IsCrossTenant(ctx) { + return baseCondition, args + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + // fail-closed: return impossible condition + return baseCondition + " AND l.tenant_id = $2", append(args, uuid.Nil) + } + return baseCondition + " AND l.tenant_id = $2", append(args, tenantID) +} + func (s *PGAgentLinkStore) CanDelegate(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (bool, error) { var exists bool err := s.db.QueryRowContext(ctx, diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 4ac5e869..e66774f2 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -104,7 +104,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, - agent_type, is_default, status, budget_monthly_cents, created_at, updated_at` + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id` func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error { if agent.ID == uuid.Nil { @@ -113,18 +113,22 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error now := time.Now() agent.CreatedAt = now agent.UpdatedAt = now + tenantID := agent.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, `INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model, context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, - agent_type, is_default, status, budget_monthly_cents, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`, agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model, agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace, jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig), jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig), - agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, + agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, tenantID, ) if err != nil { return err @@ -138,9 +142,20 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error } func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) { - row := s.db.QueryRowContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE agent_key = $1 AND deleted_at IS NULL`, agentKey) + var row *sql.Row + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = $1 AND deleted_at IS NULL`, agentKey) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: %s", agentKey) + } + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = $1 AND deleted_at IS NULL AND tenant_id = $2`, agentKey, tid) + } d, err := scanAgentRow(row) if err != nil { return nil, fmt.Errorf("agent not found: %s", agentKey) @@ -149,9 +164,20 @@ func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.Ag } func (s *PGAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) { - row := s.db.QueryRowContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE id = $1 AND deleted_at IS NULL`, id) + var row *sql.Row + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = $1 AND deleted_at IS NULL`, id) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: %s", id) + } + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = $1 AND deleted_at IS NULL AND tenant_id = $2`, id, tid) + } d, err := scanAgentRow(row) if err != nil { return nil, fmt.Errorf("agent not found: %s", id) @@ -165,20 +191,39 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str return nil } - // If setting this agent as default, unset any existing default first. + // If setting this agent as default, unset any existing default first (scoped to same tenant). if v, ok := updates["is_default"]; ok { if isDefault, _ := v.(bool); isDefault { - if _, err := s.db.ExecContext(ctx, - "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL", id); err != nil { - slog.Warn("agents.unset_default", "error", err) + if store.IsCrossTenant(ctx) { + if _, err := s.db.ExecContext(ctx, + "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL", id); err != nil { + slog.Warn("agents.unset_default", "error", err) + } + } else { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + if _, err := s.db.ExecContext(ctx, + "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL AND tenant_id = $2", id, tid); err != nil { + slog.Warn("agents.unset_default", "error", err) + } + } } } } updates["updated_at"] = time.Now() - err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id) - if err != nil { - return err + if store.IsCrossTenant(ctx) { + if err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id); err != nil { + return err + } + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("agent not found: %s", id) + } + if err := execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid); err != nil { + return err + } } // Regenerate embedding when frontmatter changes @@ -194,22 +239,37 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str } func (s *PGAgentStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("agent not found: %s", id) + } + _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1 AND tenant_id = $2", id, tid) return err } func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) { - var rows *sql.Rows - var err error + q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL` + var args []any + argIdx := 1 + if ownerID != "" { - rows, err = s.db.QueryContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE deleted_at IS NULL AND owner_id = $1 ORDER BY created_at DESC`, ownerID) - } else { - rows, err = s.db.QueryContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE deleted_at IS NULL ORDER BY created_at DESC`) + q += fmt.Sprintf(" AND owner_id = $%d", argIdx) + args = append(args, ownerID) + argIdx++ } + + if clause, targs, err := tenantClauseN(ctx, argIdx); err == nil && clause != "" { + q += clause + args = append(args, targs...) + argIdx++ + } + + q += " ORDER BY created_at DESC" + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -218,10 +278,21 @@ func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentD } func (s *PGAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE deleted_at IS NULL + ORDER BY is_default DESC, created_at ASC LIMIT 1`) + return scanAgentRow(row) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: default") + } row := s.db.QueryRowContext(ctx, `SELECT `+agentSelectCols+` - FROM agents WHERE deleted_at IS NULL - ORDER BY is_default DESC, created_at ASC LIMIT 1`) + FROM agents WHERE deleted_at IS NULL AND tenant_id = $1 + ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid) return scanAgentRow(row) } @@ -234,24 +305,43 @@ func (s *PGAgentStore) ShareAgent(ctx context.Context, agentID uuid.UUID, userID if err := store.ValidateUserID(grantedBy); err != nil { return err } + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (agent_id, user_id) DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), agentID, userID, role, grantedBy, time.Now(), + store.GenNewID(), agentID, userID, role, grantedBy, tid, time.Now(), ) return err } func (s *PGAgentStore) RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, + "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } _, err := s.db.ExecContext(ctx, - "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID) + "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2 AND tenant_id = $3", agentID, userID, tid) return err } func (s *PGAgentStore) ListShares(ctx context.Context, agentID uuid.UUID) ([]store.AgentShareData, error) { - rows, err := s.db.QueryContext(ctx, - "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = $1", agentID) + q := "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = $1" + args := []any{agentID} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = $2" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -272,9 +362,20 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID // Check ownership + default flag var ownerID string var isDefault bool - err := s.db.QueryRowContext(ctx, - "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL", agentID, - ).Scan(&ownerID, &isDefault) + var err error + if store.IsCrossTenant(ctx) { + err = s.db.QueryRowContext(ctx, + "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL", agentID, + ).Scan(&ownerID, &isDefault) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return false, "", fmt.Errorf("agent not found") + } + err = s.db.QueryRowContext(ctx, + "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL AND tenant_id = $2", agentID, tid, + ).Scan(&ownerID, &isDefault) + } if err != nil { return false, "", fmt.Errorf("agent not found") } @@ -289,9 +390,19 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID } // Check shares var role string - err = s.db.QueryRowContext(ctx, - "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID, - ).Scan(&role) + if store.IsCrossTenant(ctx) { + err = s.db.QueryRowContext(ctx, + "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID, + ).Scan(&role) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return false, "", nil + } + err = s.db.QueryRowContext(ctx, + "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2 AND tenant_id = $3", agentID, userID, tid, + ).Scan(&role) + } if err != nil { return false, "", nil } @@ -299,18 +410,49 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID } func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]store.AgentData, error) { + if store.IsCrossTenant(ctx) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents + WHERE deleted_at IS NULL AND ( + owner_id = $1 + OR is_default = true + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1) + OR ( + agent_type = 'predefined' + AND id IN ( + SELECT agent_id FROM channel_instances ci + WHERE ci.enabled = true + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af + WHERE af = $1 + ) + ) + ) + ) + ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAgentRows(rows) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } rows, err := s.db.QueryContext(ctx, `SELECT `+agentSelectCols+` FROM agents - WHERE deleted_at IS NULL AND ( + WHERE deleted_at IS NULL AND tenant_id = $2 AND ( owner_id = $1 OR is_default = true - OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1) + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1 AND tenant_id = $2) OR ( agent_type = 'predefined' AND id IN ( SELECT agent_id FROM channel_instances ci - WHERE ci.enabled = true + WHERE ci.enabled = true AND ci.tenant_id = $2 AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af WHERE af = $1 @@ -318,7 +460,7 @@ func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]sto ) ) ) - ORDER BY created_at DESC`, userID) + ORDER BY created_at DESC`, userID, tid) if err != nil { return nil, err } @@ -340,7 +482,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) { err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model, &d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace, &toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg, - &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt) + &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt, &d.TenantID) if err != nil { return nil, err } @@ -443,3 +585,29 @@ func replaceIDX(s, replacement string) string { } return result.String() } + +// execMapUpdateWhereTenant is like execMapUpdateWhere but appends an AND tenant_id = $N filter. +// Column names are validated to prevent SQL injection. +func execMapUpdateWhereTenant(ctx context.Context, db *sql.DB, table string, updates map[string]any, id, tenantID uuid.UUID) error { + if len(updates) == 0 { + return nil + } + var setClauses []string + var args []any + i := 1 + for col, val := range updates { + if !validColumnName.MatchString(col) { + slog.Warn("security.invalid_column_name", "table", table, "column", col) + return fmt.Errorf("invalid column name: %q", col) + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, i)) + args = append(args, val) + i++ + } + // $i = id, $i+1 = tenantID + args = append(args, id, tenantID) + q := fmt.Sprintf("UPDATE %s SET %s WHERE id = $%d AND tenant_id = $%d", + table, joinStrings(setClauses, ", "), i, i+1) + _, err := db.ExecContext(ctx, q, args...) + return err +} diff --git a/internal/store/pg/agents_batch.go b/internal/store/pg/agents_batch.go new file mode 100644 index 00000000..4d999164 --- /dev/null +++ b/internal/store/pg/agents_batch.go @@ -0,0 +1,76 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetByKeys returns agents matching the given keys in a single query. +// Results are tenant-scoped unless cross-tenant context is set. +func (s *PGAgentStore) GetByKeys(ctx context.Context, keys []string) ([]store.AgentData, error) { + if len(keys) == 0 { + return nil, nil + } + + var rows *sql.Rows + var err error + + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = ANY($1) AND deleted_at IS NULL`, + pq.Array(keys)) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("no tenant context for batch agent lookup") + } + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = ANY($1) AND deleted_at IS NULL AND tenant_id = $2`, + pq.Array(keys), tid) + } + if err != nil { + return nil, fmt.Errorf("batch agent key lookup: %w", err) + } + defer rows.Close() + return scanAgentRows(rows) +} + +// GetByIDs returns agents matching the given UUIDs in a single query. +// Results are tenant-scoped unless cross-tenant context is set. +func (s *PGAgentStore) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]store.AgentData, error) { + if len(ids) == 0 { + return nil, nil + } + + var rows *sql.Rows + var err error + + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = ANY($1) AND deleted_at IS NULL`, + pq.Array(ids)) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("no tenant context for batch agent lookup") + } + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = ANY($1) AND deleted_at IS NULL AND tenant_id = $2`, + pq.Array(ids), tid) + } + if err != nil { + return nil, fmt.Errorf("batch agent ID lookup: %w", err) + } + defer rows.Close() + return scanAgentRows(rows) +} diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index abafc779..de4376b0 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -17,8 +17,13 @@ import ( // --- Agent-level Context Files --- func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1", agentID) + "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause, + append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -37,10 +42,10 @@ func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UU func (s *PGAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, file_name) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`, - store.GenNewID(), agentID, fileName, content, time.Now(), + store.GenNewID(), agentID, fileName, content, time.Now(), tenantIDForInsert(ctx), ) return err } @@ -48,8 +53,13 @@ func (s *PGAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUI // --- Per-user Context Files --- func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]store.UserContextFileData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2", agentID, userID) + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause, + append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err } @@ -68,18 +78,22 @@ func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUI func (s *PGAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (agent_id, user_id, file_name) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`, - store.GenNewID(), agentID, userID, fileName, content, time.Now(), + store.GenNewID(), agentID, userID, fileName, content, time.Now(), tenantIDForInsert(ctx), ) return err } func (s *PGAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM user_context_files WHERE agent_id = $1 AND user_id = $2 AND file_name = $3", - agentID, userID, fileName) + tClause, tArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM user_context_files WHERE agent_id = $1 AND user_id = $2 AND file_name = $3"+tClause, + append([]any{agentID, userID, fileName}, tArgs...)...) return err } @@ -96,11 +110,11 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid. var isInserted bool var storedWorkspace sql.NullString err := s.db.QueryRowContext(ctx, ` - INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at) - VALUES ($1, $2, NULLIF($3, ''), NOW(), NOW()) + INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, tenant_id) + VALUES ($1, $2, NULLIF($3, ''), NOW(), NOW(), $4) ON CONFLICT (agent_id, user_id) DO UPDATE SET last_seen_at = NOW() RETURNING (xmax = 0), workspace - `, agentID, userID, effectiveWs).Scan(&isInserted, &storedWorkspace) + `, agentID, userID, effectiveWs, tenantIDForInsert(ctx)).Scan(&isInserted, &storedWorkspace) if err != nil { return false, effectiveWs, err } @@ -115,16 +129,25 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid. // Used when admin manually adds a contact as an agent instance via the UI. func (s *PGAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error { _, err := s.db.ExecContext(ctx, ` - INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at) - VALUES ($1, $2, NOW(), NOW()) + INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at, tenant_id) + VALUES ($1, $2, NOW(), NOW(), $3) ON CONFLICT (agent_id, user_id) DO NOTHING - `, agentID, userID) + `, agentID, userID, tenantIDForInsert(ctx)) return err } // --- User Instances --- func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } + // Tenant-scope the file count subquery to prevent cross-tenant leakage. + subTenantFilter := "" + if !store.IsCrossTenant(ctx) { + subTenantFilter = " AND tenant_id = $2" + } rows, err := s.db.QueryContext(ctx, ` SELECT p.user_id, TO_CHAR(p.first_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS first_seen_at, @@ -135,12 +158,12 @@ func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) LEFT JOIN ( SELECT user_id, COUNT(*) AS cnt FROM user_context_files - WHERE agent_id = $1 + WHERE agent_id = $1`+subTenantFilter+` GROUP BY user_id ) fc ON fc.user_id = p.user_id - WHERE p.agent_id = $1 + WHERE p.agent_id = $1`+tClause+` ORDER BY p.last_seen_at DESC - `, agentID) + `, append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -166,10 +189,14 @@ func (s *PGAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uu if err != nil { return err } + tClause, tArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } _, err = s.db.ExecContext(ctx, `UPDATE user_agent_profiles SET metadata = COALESCE(metadata, '{}') || $3::jsonb - WHERE agent_id = $1 AND user_id = $2`, - agentID, userID, metaJSON, + WHERE agent_id = $1 AND user_id = $2`+tClause, + append([]any{agentID, userID, metaJSON}, tArgs...)..., ) return err } @@ -177,10 +204,14 @@ func (s *PGAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uu // --- User Overrides --- func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } var d store.UserAgentOverrideData - err := s.db.QueryRowContext(ctx, - "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = $1 AND user_id = $2", - agentID, userID, + err = s.db.QueryRowContext(ctx, + "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = $1 AND user_id = $2"+tClause, + append([]any{agentID, userID}, tArgs...)..., ).Scan(&d.AgentID, &d.UserID, &d.Provider, &d.Model) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -193,10 +224,10 @@ func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, u func (s *PGAgentStore) SetUserOverride(ctx context.Context, override *store.UserAgentOverrideData) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, user_id) DO UPDATE SET provider = EXCLUDED.provider, model = EXCLUDED.model`, - store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, + store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, tenantIDForInsert(ctx), ) return err } diff --git a/internal/store/pg/api_keys.go b/internal/store/pg/api_keys.go index b81ee289..d6248d5f 100644 --- a/internal/store/pg/api_keys.go +++ b/internal/store/pg/api_keys.go @@ -3,6 +3,8 @@ package pg import ( "context" "database/sql" + "fmt" + "strings" "time" "github.com/google/uuid" @@ -22,18 +24,26 @@ func NewPGAPIKeyStore(db *sql.DB) *PGAPIKeyStore { } func (s *PGAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error { + var ownerID *string + if key.OwnerID != "" { + ownerID = &key.OwnerID + } + var tenantID *uuid.UUID + if key.TenantID != uuid.Nil { + tenantID = &key.TenantID + } _, err := s.db.ExecContext(ctx, - `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, expires_at, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, key.ID, key.Name, key.Prefix, key.KeyHash, pq.Array(key.Scopes), - key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt, + ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt, ) return err } func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, name, prefix, key_hash, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at + `SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at FROM api_keys WHERE key_hash = $1 AND NOT revoked AND (expires_at IS NULL OR expires_at > now())`, keyHash, @@ -41,9 +51,11 @@ func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.A var k store.APIKeyData var createdBy *string + var ownerID *string + var tenantID *uuid.UUID err := row.Scan( &k.ID, &k.Name, &k.Prefix, &k.KeyHash, pq.Array(&k.Scopes), - &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, + &ownerID, &tenantID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, &k.CreatedAt, &k.UpdatedAt, ) if err != nil { @@ -52,15 +64,47 @@ func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.A if createdBy != nil { k.CreatedBy = *createdBy } + if ownerID != nil { + k.OwnerID = *ownerID + } + if tenantID != nil { + k.TenantID = *tenantID + } return &k, nil } -func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) { +func (s *PGAPIKeyStore) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) { + var conditions []string + var args []any + argIdx := 1 + + if ownerID != "" { + conditions = append(conditions, fmt.Sprintf("owner_id = $%d", argIdx)) + args = append(args, ownerID) + argIdx++ + } + + // Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id) + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("(tenant_id = $%d OR tenant_id IS NULL)", argIdx)) + args = append(args, tid) + argIdx++ + } + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + rows, err := s.db.QueryContext(ctx, - `SELECT id, name, prefix, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at - FROM api_keys + `SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at + FROM api_keys`+where+` ORDER BY created_at DESC`, + args..., ) if err != nil { return nil, err @@ -71,9 +115,11 @@ func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) { for rows.Next() { var k store.APIKeyData var createdBy *string + var oID *string + var tID *uuid.UUID if err := rows.Scan( &k.ID, &k.Name, &k.Prefix, pq.Array(&k.Scopes), - &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, + &oID, &tID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, &k.CreatedAt, &k.UpdatedAt, ); err != nil { return nil, err @@ -81,16 +127,36 @@ func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) { if createdBy != nil { k.CreatedBy = *createdBy } + if oID != nil { + k.OwnerID = *oID + } + if tID != nil { + k.TenantID = *tID + } keys = append(keys, k) } return keys, rows.Err() } -func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error { - res, err := s.db.ExecContext(ctx, - `UPDATE api_keys SET revoked = true, updated_at = $2 WHERE id = $1`, - id, time.Now(), - ) +func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error { + q := "UPDATE api_keys SET revoked = true, updated_at = $1 WHERE id = $2" + args := []any{time.Now(), id} + argIdx := 3 + + if ownerID != "" { + q += fmt.Sprintf(" AND owner_id = $%d", argIdx) + args = append(args, ownerID) + argIdx++ + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx) + args = append(args, tid) + } + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } @@ -101,10 +167,25 @@ func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error { return nil } -func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID) error { - res, err := s.db.ExecContext(ctx, - `DELETE FROM api_keys WHERE id = $1`, id, - ) +func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error { + q := "DELETE FROM api_keys WHERE id = $1" + args := []any{id} + argIdx := 2 + + if ownerID != "" { + q += fmt.Sprintf(" AND owner_id = $%d", argIdx) + args = append(args, ownerID) + argIdx++ + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx) + args = append(args, tid) + } + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go index d63a4ada..84517b68 100644 --- a/internal/store/pg/channel_contacts.go +++ b/internal/store/pg/channel_contacts.go @@ -11,6 +11,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) + // PGContactStore implements store.ContactStore backed by Postgres. type PGContactStore struct { db *sql.DB @@ -22,26 +23,39 @@ func NewPGContactStore(db *sql.DB) *PGContactStore { } func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, ` - INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind) - VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,'')) - ON CONFLICT (channel_type, sender_id) DO UPDATE SET + INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id) + VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8) + ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET display_name = COALESCE(NULLIF($5,''), channel_contacts.display_name), username = COALESCE(NULLIF($6,''), channel_contacts.username), user_id = COALESCE(NULLIF($4,''), channel_contacts.user_id), channel_instance = COALESCE(NULLIF($2,''), channel_contacts.channel_instance), peer_kind = COALESCE(NULLIF($7,''), channel_contacts.peer_kind), last_seen_at = NOW()`, - channelType, channelInstance, senderID, userID, displayName, username, peerKind, + channelType, channelInstance, senderID, userID, displayName, username, peerKind, tenantID, ) return err } -func contactWhereClause(opts store.ContactListOpts) (string, []any, int) { +func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string, []any, int) { var conditions []string var args []any argIdx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + } + if opts.ChannelType != "" { conditions = append(conditions, fmt.Sprintf("channel_type = $%d", argIdx)) args = append(args, opts.ChannelType) @@ -71,7 +85,7 @@ func contactWhereClause(opts store.ContactListOpts) (string, []any, int) { } func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { - where, args, argIdx := contactWhereClause(opts) + where, args, argIdx := contactWhereClause(ctx, opts) query := `SELECT id, channel_type, channel_instance, sender_id, user_id, display_name, username, avatar_url, peer_kind, merged_id, @@ -113,7 +127,7 @@ func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactLis } func (s *PGContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) { - where, args, _ := contactWhereClause(opts) + where, args, _ := contactWhereClause(ctx, opts) var count int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count) return count, err @@ -160,12 +174,30 @@ func (s *PGContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs [ return result, rows.Err() } -func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error { - if len(contactIDs) < 2 { +func (s *PGContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*store.ChannelContact, error) { + tid := store.TenantIDFromContext(ctx) + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at + FROM channel_contacts WHERE id = $1 AND tenant_id = $2`, id, tid) + var c store.ChannelContact + if err := row.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ); err != nil { + return nil, err + } + return &c, nil +} + +func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error { + if len(contactIDs) == 0 { return nil } + tid := store.TenantIDFromContext(ctx) - // Check if any of the contacts already has a merged_id; reuse it. placeholders := make([]string, len(contactIDs)) args := make([]any, len(contactIDs)) for i, id := range contactIDs { @@ -174,22 +206,65 @@ func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UU } inClause := strings.Join(placeholders, ",") - var existingMergedID *uuid.UUID - err := s.db.QueryRowContext(ctx, - fmt.Sprintf("SELECT merged_id FROM channel_contacts WHERE id IN (%s) AND merged_id IS NOT NULL LIMIT 1", inClause), - args..., - ).Scan(&existingMergedID) - - mergedID := uuid.New() - if err == nil && existingMergedID != nil { - mergedID = *existingMergedID - } - - // Update all contacts with the merged_id. - args = append(args, mergedID) - _, err = s.db.ExecContext(ctx, - fmt.Sprintf("UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s)", len(args), inClause), - args..., + // $N+1 = tenantUserID, $N+2 = tenant_id + args = append(args, tenantUserID, tid) + q := fmt.Sprintf( + "UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s) AND tenant_id = $%d", + len(args)-1, inClause, len(args), ) + _, err := s.db.ExecContext(ctx, q, args...) return err } + +func (s *PGContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error { + if len(contactIDs) == 0 { + return nil + } + tid := store.TenantIDFromContext(ctx) + + placeholders := make([]string, len(contactIDs)) + args := make([]any, len(contactIDs)) + for i, id := range contactIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + inClause := strings.Join(placeholders, ",") + + args = append(args, tid) + q := fmt.Sprintf( + "UPDATE channel_contacts SET merged_id = NULL WHERE id IN (%s) AND tenant_id = $%d", + inClause, len(args), + ) + _, err := s.db.ExecContext(ctx, q, args...) + return err +} + +func (s *PGContactStore) GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]store.ChannelContact, error) { + tid := store.TenantIDFromContext(ctx) + + q := `SELECT id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at + FROM channel_contacts WHERE merged_id = $1 AND tenant_id = $2 + ORDER BY last_seen_at DESC` + + rows, err := s.db.QueryContext(ctx, q, mergedID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []store.ChannelContact + for rows.Next() { + var c store.ChannelContact + if err := rows.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, rows.Err() +} diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go index 15ee29d0..6807ce2f 100644 --- a/internal/store/pg/channel_instances.go +++ b/internal/store/pg/channel_instances.go @@ -28,7 +28,7 @@ func NewPGChannelInstanceStore(db *sql.DB, encryptionKey string) *PGChannelInsta } const channelInstanceSelectCols = `id, name, display_name, channel_type, agent_id, - credentials, config, enabled, created_by, created_at, updated_at` + credentials, config, enabled, created_by, created_at, updated_at, tenant_id` func (s *PGChannelInstanceStore) Create(ctx context.Context, inst *store.ChannelInstanceData) error { if err := store.ValidateUserID(inst.CreatedBy); err != nil { @@ -54,26 +54,49 @@ func (s *PGChannelInstanceStore) Create(ctx context.Context, inst *store.Channel inst.CreatedAt = now inst.UpdatedAt = now + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO channel_instances (id, name, display_name, channel_type, agent_id, - credentials, config, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + credentials, config, enabled, created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, inst.ID, inst.Name, inst.DisplayName, inst.ChannelType, inst.AgentID, credsBytes, jsonOrEmpty(inst.Config), - inst.Enabled, inst.CreatedBy, now, now, + inst.Enabled, inst.CreatedBy, now, now, tenantID, ) return err } func (s *PGChannelInstanceStore) Get(ctx context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1`, id) + return s.scanInstance(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } row := s.db.QueryRowContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1`, id) + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1 AND tenant_id = $2`, id, tenantID) return s.scanInstance(row) } func (s *PGChannelInstanceStore) GetByName(ctx context.Context, name string) (*store.ChannelInstanceData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1`, name) + return s.scanInstance(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } row := s.db.QueryRowContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1`, name) + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1 AND tenant_id = $2`, name, tenantID) return s.scanInstance(row) } @@ -86,7 +109,7 @@ func (s *PGChannelInstanceStore) scanInstance(row *sql.Row) (*store.ChannelInsta err := row.Scan( &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID, &creds, &config, - &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, + &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, &inst.TenantID, ) if err != nil { return nil, err @@ -124,7 +147,7 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI if err := rows.Scan( &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID, &creds, &config, - &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, + &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, &inst.TenantID, ); err != nil { continue } @@ -196,7 +219,14 @@ func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updat updates["credentials"] = credsBytes } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "channel_instances", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "channel_instances", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid) } // loadExistingCreds reads and decrypts the current credentials for merging. @@ -222,13 +252,31 @@ func (s *PGChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid. } func (s *PGChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1 AND tenant_id = $2", id, tid) return err } func (s *PGChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE enabled = true ORDER BY name`) + query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = true` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` AND tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } @@ -236,19 +284,38 @@ func (s *PGChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.Chann } func (s *PGChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelInstanceData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances ORDER BY name`) + query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` WHERE tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } return s.scanInstances(rows) } -func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []any) { +func buildChannelInstanceWhere(ctx context.Context, opts store.ChannelInstanceListOpts) (string, []any) { var conditions []string var args []any argIdx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + } + if opts.Search != "" { conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d ESCAPE '\\' OR display_name ILIKE $%d ESCAPE '\\' OR channel_type ILIKE $%d ESCAPE '\\')", argIdx, argIdx, argIdx)) escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) @@ -263,7 +330,7 @@ func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []an } func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) { - where, args := buildChannelInstanceWhere(opts) + where, args := buildChannelInstanceWhere(ctx, opts) limit := opts.Limit if limit <= 0 { limit = 50 @@ -279,7 +346,7 @@ func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.Chann } func (s *PGChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) { - where, args := buildChannelInstanceWhere(opts) + where, args := buildChannelInstanceWhere(ctx, opts) var count int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count) return count, err diff --git a/internal/store/pg/config_permissions.go b/internal/store/pg/config_permissions.go index 042199bb..5dfe9d75 100644 --- a/internal/store/pg/config_permissions.go +++ b/internal/store/pg/config_permissions.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "strconv" "strings" "sync" @@ -65,7 +66,11 @@ func (s *PGConfigPermissionStore) InvalidateCache() { // CheckPermission evaluates deny-first, allow-second permission with Go-level wildcard matching. func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) { - cacheKey := agentID.String() + ":" + userID + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + cacheKey := tid.String() + ":" + agentID.String() + ":" + userID // Check cache. s.mu.RLock() @@ -76,10 +81,14 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u s.mu.RUnlock() // Fetch from DB. + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return false, err + } rows, err := s.db.QueryContext(ctx, `SELECT scope, config_type, permission FROM agent_config_permissions - WHERE agent_id = $1 AND user_id = $2`, - agentID, userID, + WHERE agent_id = $1 AND user_id = $2`+tClause, + append([]any{agentID, userID}, tArgs...)..., ) if err != nil { return false, err @@ -146,14 +155,14 @@ func (s *PGConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigP } now := time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) + `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8,$9) ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET permission = EXCLUDED.permission, granted_by = EXCLUDED.granted_by, metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at`, - perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, + perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, tenantIDForInsert(ctx), ) if err == nil { s.InvalidateCache() @@ -162,9 +171,13 @@ func (s *PGConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigP } func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error { - _, err := s.db.ExecContext(ctx, - `DELETE FROM agent_config_permissions WHERE agent_id = $1 AND scope = $2 AND config_type = $3 AND user_id = $4`, - agentID, scope, configType, userID, + tClause, tArgs, err := tenantClauseN(ctx, 5) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM agent_config_permissions WHERE agent_id = $1 AND scope = $2 AND config_type = $3 AND user_id = $4`+tClause, + append([]any{agentID, scope, configType, userID}, tArgs...)..., ) if err == nil { s.InvalidateCache() @@ -173,6 +186,10 @@ func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, } func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) { + tClause, tArgs, err := tenantClauseN(ctx, 0) // paramN unused; we append manually + if err != nil { + return nil, err + } query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at FROM agent_config_permissions WHERE agent_id = $1` args := []any{agentID} @@ -185,6 +202,10 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c args = append(args, scope) query += ` AND scope = $` + itoa(len(args)) } + if tClause != "" { + args = append(args, tArgs...) + query += fmt.Sprintf(" AND tenant_id = $%d", len(args)) + } query += ` ORDER BY created_at` rows, err := s.db.QueryContext(ctx, query, args...) @@ -199,7 +220,11 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c // ListFileWriters returns cached file_writer allow permissions for a given agentID+scope. // Hot-path: called during system prompt injection for every group message. func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) { - cacheKey := agentID.String() + ":" + scope + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + cacheKey := tid.String() + ":" + agentID.String() + ":" + scope s.fwMu.RLock() if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL { @@ -208,12 +233,16 @@ func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID u } s.fwMu.RUnlock() + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at FROM agent_config_permissions - WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow' + WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow'`+tClause+` ORDER BY created_at`, - agentID, scope, + append([]any{agentID, scope}, tArgs...)..., ) if err != nil { return nil, err diff --git a/internal/store/pg/config_secrets.go b/internal/store/pg/config_secrets.go index e5281239..fb222ff4 100644 --- a/internal/store/pg/config_secrets.go +++ b/internal/store/pg/config_secrets.go @@ -21,9 +21,10 @@ func NewPGConfigSecretsStore(db *sql.DB, encryptionKey string) *PGConfigSecretsS } func (s *PGConfigSecretsStore) Get(ctx context.Context, key string) (string, error) { + tid := tenantIDForInsert(ctx) // fallback to master var value []byte err := s.db.QueryRowContext(ctx, - `SELECT value FROM config_secrets WHERE key = $1`, key).Scan(&value) + `SELECT value FROM config_secrets WHERE key = $1 AND tenant_id = $2`, key, tid).Scan(&value) if err != nil { return "", err } @@ -50,21 +51,24 @@ func (s *PGConfigSecretsStore) Set(ctx context.Context, key, value string) error stored = []byte(value) } + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, - `INSERT INTO config_secrets (key, value, updated_at) VALUES ($1, $2, $3) - ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = $3`, - key, stored, time.Now(), + `INSERT INTO config_secrets (key, value, updated_at, tenant_id) VALUES ($1, $2, $3, $4) + ON CONFLICT (key, tenant_id) DO UPDATE SET value = $2, updated_at = $3`, + key, stored, time.Now(), tid, ) return err } func (s *PGConfigSecretsStore) Delete(ctx context.Context, key string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM config_secrets WHERE key = $1`, key) + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, `DELETE FROM config_secrets WHERE key = $1 AND tenant_id = $2`, key, tid) return err } func (s *PGConfigSecretsStore) GetAll(ctx context.Context) (map[string]string, error) { - rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config_secrets`) + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config_secrets WHERE tenant_id = $1`, tid) if err != nil { return nil, err } diff --git a/internal/store/pg/cron_crud.go b/internal/store/pg/cron_crud.go index 4e695ab7..bebe05e1 100644 --- a/internal/store/pg/cron_crud.go +++ b/internal/store/pg/cron_crud.go @@ -1,8 +1,10 @@ package pg import ( + "context" "encoding/json" "fmt" + "log/slog" "time" "github.com/google/uuid" @@ -10,7 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { +func (s *PGCronStore) AddJob(ctx context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { // Apply default timezone for cron expressions when not set per-job. if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" { schedule.TZ = s.defaultTZ @@ -65,10 +67,10 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s nextRun := computeNextRun(&schedule, now, s.defaultTZ) _, err := s.db.Exec( - `INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + `INSERT INTO cron_jobs (id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, true, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - id, agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, + VALUES ($1, $2, $3, $4, $5, true, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + id, tenantIDForInsert(ctx), agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now, ) if err != nil { @@ -77,24 +79,24 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s s.cacheLoaded = false // invalidate cache - job, _ := s.GetJob(id.String()) + job, _ := s.GetJob(ctx, id.String()) return job, nil } -func (s *PGCronStore) GetJob(jobID string) (*store.CronJob, bool) { +func (s *PGCronStore) GetJob(ctx context.Context, jobID string) (*store.CronJob, bool) { id, err := uuid.Parse(jobID) if err != nil { return nil, false } - job, err := s.scanJob(id) + job, err := s.scanJob(ctx, id) if err != nil { return nil, false } return job, true } -func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []store.CronJob { - q := `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, +func (s *PGCronStore) ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []store.CronJob { + q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, created_at, updated_at FROM cron_jobs WHERE 1=1` @@ -118,6 +120,16 @@ func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []s args = append(args, userID) argIdx++ } + clause, targs, tErr := tenantClauseN(ctx, argIdx) + if tErr != nil { + slog.Warn("cron.ListJobs: tenant context missing, returning empty (fail-closed)", "error", tErr) + return nil + } + if clause != "" { + q += clause + args = append(args, targs...) + argIdx++ + } q += " ORDER BY created_at DESC" @@ -138,28 +150,60 @@ func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []s return result } -func (s *PGCronStore) RemoveJob(jobID string) error { +func (s *PGCronStore) RemoveJob(ctx context.Context, jobID string) error { id, err := uuid.Parse(jobID) if err != nil { return fmt.Errorf("invalid job ID: %s", jobID) } - _, err = s.db.Exec("DELETE FROM cron_jobs WHERE id = $1", id) + + q := "DELETE FROM cron_jobs WHERE id = $1" + args := []any{id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("job not found") + } s.cacheLoaded = false return nil } -func (s *PGCronStore) EnableJob(jobID string, enabled bool) error { +func (s *PGCronStore) EnableJob(ctx context.Context, jobID string, enabled bool) error { id, err := uuid.Parse(jobID) if err != nil { return fmt.Errorf("invalid job ID: %s", jobID) } - _, err = s.db.Exec("UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3", enabled, time.Now(), id) + + q := "UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3" + args := []any{enabled, time.Now(), id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("job not found") + } s.cacheLoaded = false return nil } diff --git a/internal/store/pg/cron_exec.go b/internal/store/pg/cron_exec.go index 9a7bec99..0c8da01c 100644 --- a/internal/store/pg/cron_exec.go +++ b/internal/store/pg/cron_exec.go @@ -1,6 +1,7 @@ package pg import ( + "context" "database/sql" "fmt" "time" @@ -10,8 +11,8 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { - job, ok := s.GetJob(jobID) +func (s *PGCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) { + job, ok := s.GetJob(ctx, jobID) if !ok { return false, "", fmt.Errorf("job %s not found", jobID) } @@ -32,7 +33,7 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { s.cacheLoaded = false s.mu.Unlock() - s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name}) + s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID}) // Use executeOneJob for proper state updates, run logging, and retry s.executeOneJob(*job, handler) @@ -42,7 +43,7 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { return true, "", nil } -func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { +func (s *PGCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { if limit <= 0 { limit = 20 } @@ -50,24 +51,47 @@ func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRu offset = 0 } - const cols = "job_id, status, error, summary, ran_at, COALESCE(duration_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0)" + const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)" + + // Build tenant-aware WHERE clause via JOIN with cron_jobs. + var tenantJoin, tenantWhere string + var tenantArgs []any + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id" + tenantWhere = " AND j.tenant_id = $1" + tenantArgs = append(tenantArgs, tid) + } + } var total int var rows *sql.Rows var err error + if jobID != "" { id, parseErr := uuid.Parse(jobID) if parseErr != nil { return nil, 0 } - s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs WHERE job_id = $1", id).Scan(&total) - rows, err = s.db.Query( - "SELECT "+cols+" FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2 OFFSET $3", - id, limit, offset) + argIdx := len(tenantArgs) + 1 + countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = $%d%s", tenantJoin, argIdx, tenantWhere) + countArgs := append(tenantArgs, id) + s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) + + dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = $%d%s ORDER BY r.ran_at DESC LIMIT $%d OFFSET $%d", + cols, tenantJoin, argIdx, tenantWhere, argIdx+1, argIdx+2) + dataArgs := append(tenantArgs, id, limit, offset) + rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...) } else { - s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs").Scan(&total) - rows, err = s.db.Query( - "SELECT "+cols+" FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1 OFFSET $2", limit, offset) + argIdx := len(tenantArgs) + 1 + countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere) + s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total) + + dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT $%d OFFSET $%d", + cols, tenantJoin, tenantWhere, argIdx, argIdx+1) + dataArgs := append(tenantArgs, limit, offset) + rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...) } if err != nil { return nil, 0 diff --git a/internal/store/pg/cron_scan.go b/internal/store/pg/cron_scan.go index 8d9d7ff0..4ea4217a 100644 --- a/internal/store/pg/cron_scan.go +++ b/internal/store/pg/cron_scan.go @@ -1,8 +1,10 @@ package pg import ( + "context" "database/sql" "encoding/json" + "fmt" "time" "github.com/adhocore/gronx" @@ -11,11 +13,23 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) scanJob(id uuid.UUID) (*store.CronJob, error) { - row := s.db.QueryRow( - `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, +// scanJob fetches a single cron job by ID with tenant filtering. +func (s *PGCronStore) scanJob(ctx context.Context, id uuid.UUID) (*store.CronJob, error) { + q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, - created_at, updated_at FROM cron_jobs WHERE id = $1`, id) + created_at, updated_at FROM cron_jobs WHERE id = $1` + args := []any{id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + + row := s.db.QueryRowContext(ctx, q, args...) return scanCronSingleRow(row) } @@ -27,6 +41,7 @@ type cronRowScanner interface { func scanCronRow(row cronRowScanner) (*store.CronJob, error) { var id uuid.UUID + var tenantID uuid.UUID var agentID *uuid.UUID var userID *string var name, scheduleKind string @@ -37,7 +52,7 @@ func scanCronRow(row cronRowScanner) (*store.CronJob, error) { var payloadJSON []byte var createdAt, updatedAt time.Time - err := row.Scan(&id, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz, + err := row.Scan(&id, &tenantID, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz, &intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError, &createdAt, &updatedAt) if err != nil { @@ -48,9 +63,10 @@ func scanCronRow(row cronRowScanner) (*store.CronJob, error) { json.Unmarshal(payloadJSON, &payload) job := &store.CronJob{ - ID: id.String(), - Name: name, - Enabled: enabled, + ID: id.String(), + TenantID: tenantID, + Name: name, + Enabled: enabled, Schedule: store.CronSchedule{ Kind: scheduleKind, }, diff --git a/internal/store/pg/cron_scheduler.go b/internal/store/pg/cron_scheduler.go index 442a530c..e8c97c0b 100644 --- a/internal/store/pg/cron_scheduler.go +++ b/internal/store/pg/cron_scheduler.go @@ -34,7 +34,7 @@ func (s *PGCronStore) GetDueJobs(now time.Time) []store.CronJob { // refreshJobCache reloads all enabled jobs from DB. Must be called with mu held. func (s *PGCronStore) refreshJobCache() { rows, err := s.db.Query( - `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, created_at, updated_at FROM cron_jobs WHERE enabled = true`) if err != nil { @@ -244,7 +244,7 @@ func (s *PGCronStore) executeOneJob(job store.CronJob, handler func(job *store.C } // Emit completion event - evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, Status: status} + evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status} if err != nil { evt.Action = "error" evt.Error = err.Error() diff --git a/internal/store/pg/cron_update.go b/internal/store/pg/cron_update.go index 01e1f545..f845da7e 100644 --- a/internal/store/pg/cron_update.go +++ b/internal/store/pg/cron_update.go @@ -12,7 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.CronJob, error) { +func (s *PGCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { id, err := uuid.Parse(jobID) if err != nil { return nil, fmt.Errorf("invalid job ID: %s", jobID) @@ -25,14 +25,25 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. if patch.Enabled != nil { updates["enabled"] = *patch.Enabled } + // Build tenant WHERE suffix for internal reads (defense-in-depth). + tenantSuffix := "" + var tenantArg []any + if !store.IsCrossTenant(ctx) { + if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { + tenantSuffix = " AND tenant_id = $2" + tenantArg = append(tenantArg, tid) + } + } + if patch.Schedule != nil { // Fetch current schedule to merge with patch (partial updates) var curKind string var curExpr, curTZ *string var curIntervalMS *int64 var curRunAt *time.Time - s.db.QueryRow( - "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1", id, + s.db.QueryRowContext(ctx, + "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1"+tenantSuffix, + append([]any{id}, tenantArg...)..., ).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt) // Resolve the effective schedule kind @@ -151,7 +162,7 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil || patch.WakeHeartbeat != nil if needsPayloadUpdate { var payloadJSON []byte - if scanErr := s.db.QueryRow("SELECT payload FROM cron_jobs WHERE id = $1", id).Scan(&payloadJSON); scanErr == nil { + if scanErr := s.db.QueryRowContext(ctx, "SELECT payload FROM cron_jobs WHERE id = $1"+tenantSuffix, append([]any{id}, tenantArg...)...).Scan(&payloadJSON); scanErr == nil { var payload store.CronPayload json.Unmarshal(payloadJSON, &payload) @@ -178,11 +189,22 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. updates["updated_at"] = time.Now() - if err := execMapUpdate(context.Background(), s.db, "cron_jobs", id, updates); err != nil { + var execErr error + if store.IsCrossTenant(ctx) { + execErr = execMapUpdate(ctx, s.db, "cron_jobs", id, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + execErr = fmt.Errorf("tenant_id required for update") + } else { + execErr = execMapUpdateWhereTenant(ctx, s.db, "cron_jobs", updates, id, tid) + } + } + if err := execErr; err != nil { return nil, err } s.cacheLoaded = false - job, _ := s.scanJob(id) + job, _ := s.scanJob(ctx, id) return job, nil } diff --git a/internal/store/pg/custom_tools.go b/internal/store/pg/custom_tools.go deleted file mode 100644 index 37026bce..00000000 --- a/internal/store/pg/custom_tools.go +++ /dev/null @@ -1,236 +0,0 @@ -package pg - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/crypto" - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// PGCustomToolStore implements store.CustomToolStore backed by Postgres. -type PGCustomToolStore struct { - db *sql.DB - encKey string -} - -func NewPGCustomToolStore(db *sql.DB, encryptionKey string) *PGCustomToolStore { - return &PGCustomToolStore{db: db, encKey: encryptionKey} -} - -const customToolSelectCols = `id, name, description, parameters, command, working_dir, - timeout_seconds, env, agent_id, enabled, created_by, created_at, updated_at` - -func (s *PGCustomToolStore) Create(ctx context.Context, def *store.CustomToolDef) error { - if err := store.ValidateUserID(def.CreatedBy); err != nil { - return err - } - if def.ID == uuid.Nil { - def.ID = store.GenNewID() - } - - // Encrypt env if provided - var envBytes []byte - if len(def.Env) > 0 && s.encKey != "" { - encrypted, err := crypto.Encrypt(string(def.Env), s.encKey) - if err != nil { - return fmt.Errorf("encrypt env: %w", err) - } - envBytes = []byte(encrypted) - } else { - envBytes = def.Env - } - - now := time.Now() - def.CreatedAt = now - def.UpdatedAt = now - - _, err := s.db.ExecContext(ctx, - `INSERT INTO custom_tools (id, name, description, parameters, command, working_dir, - timeout_seconds, env, agent_id, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, - def.ID, def.Name, def.Description, - jsonOrEmpty(def.Parameters), - def.Command, nilStr(def.WorkingDir), - def.TimeoutSeconds, envBytes, - nilUUID(def.AgentID), def.Enabled, - def.CreatedBy, now, now, - ) - return err -} - -func (s *PGCustomToolStore) Get(ctx context.Context, id uuid.UUID) (*store.CustomToolDef, error) { - row := s.db.QueryRowContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE id = $1`, id) - return s.scanTool(row) -} - -func (s *PGCustomToolStore) scanTool(row *sql.Row) (*store.CustomToolDef, error) { - var def store.CustomToolDef - var workingDir *string - var agentID *uuid.UUID - var params *[]byte // pgx workaround: can't scan NULL JSONB into *json.RawMessage - var env []byte - - err := row.Scan( - &def.ID, &def.Name, &def.Description, ¶ms, - &def.Command, &workingDir, - &def.TimeoutSeconds, &env, &agentID, - &def.Enabled, &def.CreatedBy, &def.CreatedAt, &def.UpdatedAt, - ) - if err != nil { - return nil, err - } - - def.WorkingDir = derefStr(workingDir) - def.AgentID = agentID - if params != nil { - def.Parameters = *params - } - - // Decrypt env - if len(env) > 0 && s.encKey != "" { - decrypted, err := crypto.Decrypt(string(env), s.encKey) - if err != nil { - slog.Warn("custom_tools: failed to decrypt env", "tool", def.Name, "error", err) - } else { - def.Env = []byte(decrypted) - } - } else { - def.Env = env - } - - return &def, nil -} - -func (s *PGCustomToolStore) scanTools(rows *sql.Rows) ([]store.CustomToolDef, error) { - defer rows.Close() - var result []store.CustomToolDef - for rows.Next() { - var def store.CustomToolDef - var workingDir *string - var agentID *uuid.UUID - var params *[]byte - var env []byte - - if err := rows.Scan( - &def.ID, &def.Name, &def.Description, ¶ms, - &def.Command, &workingDir, - &def.TimeoutSeconds, &env, &agentID, - &def.Enabled, &def.CreatedBy, &def.CreatedAt, &def.UpdatedAt, - ); err != nil { - continue - } - - def.WorkingDir = derefStr(workingDir) - def.AgentID = agentID - if params != nil { - def.Parameters = *params - } - if len(env) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { - def.Env = []byte(decrypted) - } - } else { - def.Env = env - } - - result = append(result, def) - } - return result, nil -} - -func (s *PGCustomToolStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { - // Encrypt env if present - if envVal, ok := updates["env"]; ok { - if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" { - encrypted, err := crypto.Encrypt(envStr, s.encKey) - if err != nil { - return fmt.Errorf("encrypt env: %w", err) - } - updates["env"] = []byte(encrypted) - } - } - updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "custom_tools", id, updates) -} - -func (s *PGCustomToolStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM custom_tools WHERE id = $1", id) - return err -} - -func (s *PGCustomToolStore) ListGlobal(ctx context.Context) ([]store.CustomToolDef, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE agent_id IS NULL AND enabled = true ORDER BY name`) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func (s *PGCustomToolStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.CustomToolDef, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE agent_id = $1 AND enabled = true ORDER BY name`, agentID) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func (s *PGCustomToolStore) ListAll(ctx context.Context) ([]store.CustomToolDef, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE enabled = true ORDER BY name`) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func buildCustomToolWhere(opts store.CustomToolListOpts) (string, []any) { - conditions := []string{"enabled = true"} - var args []any - argIdx := 1 - - if opts.AgentID != nil { - conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) - args = append(args, *opts.AgentID) - argIdx++ - } - if opts.Search != "" { - conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d ESCAPE '\\' OR description ILIKE $%d ESCAPE '\\')", argIdx, argIdx)) - escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) - args = append(args, "%"+escaped+"%") - } - - return " WHERE " + strings.Join(conditions, " AND "), args -} - -func (s *PGCustomToolStore) ListPaged(ctx context.Context, opts store.CustomToolListOpts) ([]store.CustomToolDef, error) { - where, args := buildCustomToolWhere(opts) - limit := opts.Limit - if limit <= 0 { - limit = 50 - } - q := `SELECT ` + customToolSelectCols + ` FROM custom_tools` + where + - fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit) - - rows, err := s.db.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func (s *PGCustomToolStore) CountTools(ctx context.Context, opts store.CustomToolListOpts) (int, error) { - where, args := buildCustomToolWhere(opts) - var count int - err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM custom_tools"+where, args...).Scan(&count) - return count, err -} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index dd777c1e..563e4c54 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -32,7 +32,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Providers: NewPGProviderStore(db, cfg.EncryptionKey), Tracing: NewPGTracingStore(db), MCP: NewPGMCPServerStore(db, cfg.EncryptionKey), - CustomTools: NewPGCustomToolStore(db, cfg.EncryptionKey), ChannelInstances: NewPGChannelInstanceStore(db, cfg.EncryptionKey), ConfigSecrets: NewPGConfigSecretsStore(db, cfg.EncryptionKey), AgentLinks: NewPGAgentLinkStore(db), @@ -46,6 +45,9 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey), APIKeys: NewPGAPIKeyStore(db), Heartbeats: NewPGHeartbeatStore(db), - ConfigPermissions: NewPGConfigPermissionStore(db), + ConfigPermissions: NewPGConfigPermissionStore(db), + Tenants: NewPGTenantStore(db), + BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db), + SkillTenantCfgs: NewPGSkillTenantConfigStore(db), }, nil } diff --git a/internal/store/pg/helpers.go b/internal/store/pg/helpers.go index 683a80bb..ff748d21 100644 --- a/internal/store/pg/helpers.go +++ b/internal/store/pg/helpers.go @@ -11,6 +11,8 @@ import ( "time" "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" ) // validColumnName matches safe SQL identifiers (letters, digits, underscores). @@ -195,15 +197,50 @@ func execMapUpdate(ctx context.Context, db *sql.DB, table string, id uuid.UUID, // tablesWithUpdatedAt lists tables that have an updated_at column. var tablesWithUpdatedAt = map[string]bool{ "agents": true, "llm_providers": true, "sessions": true, - "channel_instances": true, "cron_jobs": true, "custom_tools": true, + "channel_instances": true, "cron_jobs": true, "skills": true, "mcp_servers": true, "agent_links": true, "agent_teams": true, "team_tasks": true, "builtin_tools": true, "agent_context_files": true, "user_context_files": true, "user_agent_overrides": true, "config_secrets": true, "memory_documents": true, "memory_chunks": true, "embedding_cache": true, - "secure_cli_binaries": true, + "secure_cli_binaries": true, "tenants": true, } func tableHasUpdatedAt(table string) bool { return tablesWithUpdatedAt[table] } + +// --- Tenant filter helpers --- + +// tenantClauseN returns an " AND tenant_id = $N" clause and the tenant UUID as the arg. +// Returns ("", nil, nil) for cross-tenant callers (skip filter). +// Returns error if tenant is missing from context (fail-closed). +func tenantClauseN(ctx context.Context, paramN int) (clause string, args []any, err error) { + if store.IsCrossTenant(ctx) { + return "", nil, nil + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return "", nil, fmt.Errorf("tenant_id required") + } + return fmt.Sprintf(" AND tenant_id = $%d", paramN), []any{tid}, nil +} + +// tenantIDForInsert returns the tenant UUID for INSERT operations. +// Falls back to MasterTenantID when no tenant in context. +func tenantIDForInsert(ctx context.Context) uuid.UUID { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return store.MasterTenantID + } + return tid +} + +// requireTenantID returns the tenant UUID or an error if missing (fail-closed). +func requireTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required") + } + return tid, nil +} diff --git a/internal/store/pg/knowledge_graph.go b/internal/store/pg/knowledge_graph.go index e7b75877..55bfe98d 100644 --- a/internal/store/pg/knowledge_graph.go +++ b/internal/store/pg/knowledge_graph.go @@ -39,10 +39,11 @@ func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store. } now := time.Now() id := uuid.Must(uuid.NewV7()) + tid := tenantIDForInsert(ctx) _, err = s.db.ExecContext(ctx, ` INSERT INTO kg_entities - (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) + (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET name = EXCLUDED.name, entity_type = EXCLUDED.entity_type, @@ -50,9 +51,10 @@ func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store. properties = EXCLUDED.properties, source_id = EXCLUDED.source_id, confidence = EXCLUDED.confidence, + tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at`, id, aid, entity.UserID, entity.ExternalID, entity.Name, entity.EntityType, - entity.Description, props, entity.SourceID, entity.Confidence, now, + entity.Description, props, entity.SourceID, entity.Confidence, tid, now, ) return err } @@ -62,20 +64,28 @@ func (s *PGKnowledgeGraphStore) GetEntity(ctx context.Context, agentID, userID, eid := mustParseUUID(entityID) if store.IsSharedKG(ctx) { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } row := s.db.QueryRowContext(ctx, ` SELECT id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at - FROM kg_entities WHERE id = $1 AND agent_id = $2`, - eid, aid, + FROM kg_entities WHERE id = $1 AND agent_id = $2`+tc, + append([]any{eid, aid}, tcArgs...)..., ) return scanEntity(row) } + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } row := s.db.QueryRowContext(ctx, ` SELECT id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at - FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`, - eid, aid, userID, + FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc, + append([]any{eid, aid, userID}, tcArgs...)..., ) return scanEntity(row) } @@ -84,15 +94,23 @@ func (s *PGKnowledgeGraphStore) DeleteEntity(ctx context.Context, agentID, userI aid := mustParseUUID(agentID) eid := mustParseUUID(entityID) if store.IsSharedKG(ctx) { - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2`, - eid, aid, + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2`+tc, + append([]any{eid, aid}, tcArgs...)..., ) return err } - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`, - eid, aid, userID, + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc, + append([]any{eid, aid, userID}, tcArgs...)..., ) return err } @@ -118,6 +136,15 @@ func (s *PGKnowledgeGraphStore) ListEntities(ctx context.Context, agentID, userI args = append(args, opts.EntityType) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, limit, opts.Offset) query := fmt.Sprintf(` SELECT id, agent_id, user_id, external_id, name, entity_type, description, @@ -201,6 +228,15 @@ func (s *PGKnowledgeGraphStore) ilikeSearchEntities(ctx context.Context, agentID args = append(args, userID) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, pattern, limit) q := fmt.Sprintf(` SELECT id, agent_id, user_id, external_id, name, entity_type, description, @@ -247,6 +283,15 @@ func (s *PGKnowledgeGraphStore) vectorSearchEntities(ctx context.Context, embedd args = append(args, userID) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, vecStr, limit) q := fmt.Sprintf(` SELECT id, agent_id, user_id, external_id, name, entity_type, description, @@ -330,14 +375,16 @@ func (s *PGKnowledgeGraphStore) UpsertRelation(ctx context.Context, relation *st } id := uuid.Must(uuid.NewV7()) now := time.Now() + tid := tenantIDForInsert(ctx) _, err = s.db.ExecContext(ctx, ` INSERT INTO kg_relations - (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET confidence = EXCLUDED.confidence, - properties = EXCLUDED.properties`, - id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, now, + properties = EXCLUDED.properties, + tenant_id = EXCLUDED.tenant_id`, + id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, tid, now, ) return err } @@ -346,15 +393,23 @@ func (s *PGKnowledgeGraphStore) DeleteRelation(ctx context.Context, agentID, use aid := mustParseUUID(agentID) rid := mustParseUUID(relationID) if store.IsSharedKG(ctx) { - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2`, - rid, aid, + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2`+tc, + append([]any{rid, aid}, tcArgs...)..., ) return err } - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`, - rid, aid, userID, + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc, + append([]any{rid, aid, userID}, tcArgs...)..., ) return err } @@ -366,21 +421,29 @@ func (s *PGKnowledgeGraphStore) ListRelations(ctx context.Context, agentID, user var q string var args []any if store.IsSharedKG(ctx) { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } q = `SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at FROM kg_relations WHERE agent_id = $1 - AND (source_entity_id = $2 OR target_entity_id = $2) + AND (source_entity_id = $2 OR target_entity_id = $2)` + tc + ` ORDER BY created_at DESC` - args = []any{aid, eid} + args = append([]any{aid, eid}, tcArgs...) } else { + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } q = `SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at FROM kg_relations WHERE agent_id = $1 AND user_id = $2 - AND (source_entity_id = $3 OR target_entity_id = $3) + AND (source_entity_id = $3 OR target_entity_id = $3)` + tc + ` ORDER BY created_at DESC` - args = []any{aid, userID, eid} + args = append([]any{aid, userID, eid}, tcArgs...) } rows, err := s.db.QueryContext(ctx, q, args...) @@ -404,6 +467,15 @@ func (s *PGKnowledgeGraphStore) ListAllRelations(ctx context.Context, agentID, u args = append(args, userID) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, limit) q := fmt.Sprintf(` SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, @@ -427,6 +499,7 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u aid := mustParseUUID(agentID) now := time.Now() + tid := tenantIDForInsert(ctx) // Upsert entities and build external_id → DB UUID lookup for relations extIDToUUID := make(map[string]uuid.UUID, len(entities)) @@ -440,8 +513,8 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u var actualID uuid.UUID if err := tx.QueryRowContext(ctx, ` INSERT INTO kg_entities - (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) + (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET name = EXCLUDED.name, entity_type = EXCLUDED.entity_type, @@ -449,10 +522,11 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u properties = EXCLUDED.properties, source_id = EXCLUDED.source_id, confidence = EXCLUDED.confidence, + tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at RETURNING id`, id, aid, userID, e.ExternalID, e.Name, e.EntityType, - e.Description, props, e.SourceID, e.Confidence, now, + e.Description, props, e.SourceID, e.Confidence, tid, now, ).Scan(&actualID); err != nil { return err } @@ -500,12 +574,13 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u id := uuid.Must(uuid.NewV7()) if _, err := tx.ExecContext(ctx, ` INSERT INTO kg_relations - (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET confidence = EXCLUDED.confidence, - properties = EXCLUDED.properties`, - id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, now, + properties = EXCLUDED.properties, + tenant_id = EXCLUDED.tenant_id`, + id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, tid, now, ); err != nil { return err } @@ -519,14 +594,22 @@ func (s *PGKnowledgeGraphStore) PruneByConfidence(ctx context.Context, agentID, var res sql.Result var err error if store.IsSharedKG(ctx) { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return 0, tcErr + } res, err = s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE agent_id = $1 AND confidence < $2`, - aid, minConfidence, + `DELETE FROM kg_entities WHERE agent_id = $1 AND confidence < $2`+tc, + append([]any{aid, minConfidence}, tcArgs...)..., ) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return 0, tcErr + } res, err = s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`, - aid, userID, minConfidence, + `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`+tc, + append([]any{aid, userID, minConfidence}, tcArgs...)..., ) } if err != nil { @@ -542,24 +625,32 @@ func (s *PGKnowledgeGraphStore) Stats(ctx context.Context, agentID, userID strin userFilter := "" args := []any{aid} + idx := 2 if userID != "" { - userFilter = " AND user_id = $2" + userFilter = fmt.Sprintf(" AND user_id = $%d", idx) args = append(args, userID) + idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + tenantFilter := tc + args = append(args, tcArgs...) if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter, args..., + `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+tenantFilter, args..., ).Scan(&stats.EntityCount); err != nil { return nil, err } if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter, args..., + `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter+tenantFilter, args..., ).Scan(&stats.RelationCount); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, - `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+` GROUP BY entity_type`, args..., + `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+tenantFilter+` GROUP BY entity_type`, args..., ) if err != nil { return nil, err diff --git a/internal/store/pg/knowledge_graph_embedding.go b/internal/store/pg/knowledge_graph_embedding.go index 53cc3aa1..96dcaea2 100644 --- a/internal/store/pg/knowledge_graph_embedding.go +++ b/internal/store/pg/knowledge_graph_embedding.go @@ -2,6 +2,7 @@ package pg import ( "context" + "fmt" "log/slog" "github.com/google/uuid" @@ -17,12 +18,20 @@ func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int, const batchSize = 50 total := 0 + // $1=batchSize (LIMIT), tenant at $2 if not cross-tenant + tc, tcArgs, tcErr := tenantClauseN(ctx, 2) + if tcErr != nil { + return 0, tcErr + } + batchQ := fmt.Sprintf(`SELECT id, name, description FROM kg_entities + WHERE embedding IS NULL%s + ORDER BY created_at DESC + LIMIT $1`, tc) + batchBaseArgs := tcArgs // tenant uuid(s) prepended after $1 + for { - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, description FROM kg_entities - WHERE embedding IS NULL - ORDER BY created_at DESC - LIMIT $1`, batchSize) + queryArgs := append([]any{batchSize}, batchBaseArgs...) + rows, err := s.db.QueryContext(ctx, batchQ, queryArgs...) if err != nil { return total, err } diff --git a/internal/store/pg/knowledge_graph_traversal.go b/internal/store/pg/knowledge_graph_traversal.go index 2f40f7e9..669470f4 100644 --- a/internal/store/pg/knowledge_graph_traversal.go +++ b/internal/store/pg/knowledge_graph_traversal.go @@ -3,6 +3,7 @@ package pg import ( "context" "encoding/json" + "fmt" "time" "github.com/lib/pq" @@ -33,7 +34,13 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s var q string var args []any if store.IsSharedKG(ctx) { - q = ` + // fixed params: $1=startID, $2=aid; tenant at $3 (if needed); maxDepth last + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return nil, tcErr + } + depthN := 3 + len(tcArgs) + q = fmt.Sprintf(` WITH RECURSIVE paths AS ( SELECT e.id, e.agent_id, e.user_id, e.external_id, @@ -44,7 +51,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s ARRAY[e.id::text] AS path, ''::text AS via FROM kg_entities e - WHERE e.id = $1 AND e.agent_id = $2 + WHERE e.id = $1 AND e.agent_id = $2%s UNION ALL @@ -59,7 +66,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s FROM paths p JOIN kg_relations r ON p.id = r.source_entity_id AND r.agent_id = $2 JOIN kg_entities e ON r.target_entity_id = e.id AND e.agent_id = $2 - WHERE p.depth < $3 + WHERE p.depth < $%d AND NOT e.id::text = ANY(p.path) ) SELECT @@ -68,10 +75,17 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s properties, source_id, confidence, created_at, updated_at, depth, path, via - FROM paths WHERE depth > 1` - args = []any{startID, aid, maxDepth} + FROM paths WHERE depth > 1`, tc, depthN) + args = append([]any{startID, aid}, tcArgs...) + args = append(args, maxDepth) } else { - q = ` + // fixed params: $1=startID, $2=aid, $3=userID; tenant at $4 (if needed); maxDepth last + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return nil, tcErr + } + depthN := 4 + len(tcArgs) + q = fmt.Sprintf(` WITH RECURSIVE paths AS ( SELECT e.id, e.agent_id, e.user_id, e.external_id, @@ -82,7 +96,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s ARRAY[e.id::text] AS path, ''::text AS via FROM kg_entities e - WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3 + WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3%s UNION ALL @@ -97,7 +111,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s FROM paths p JOIN kg_relations r ON p.id = r.source_entity_id AND r.user_id = $3 JOIN kg_entities e ON r.target_entity_id = e.id AND e.user_id = $3 - WHERE p.depth < $4 + WHERE p.depth < $%d AND NOT e.id::text = ANY(p.path) ) SELECT @@ -106,8 +120,9 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s properties, source_id, confidence, created_at, updated_at, depth, path, via - FROM paths WHERE depth > 1` - args = []any{startID, aid, userID, maxDepth} + FROM paths WHERE depth > 1`, tc, depthN) + args = append([]any{startID, aid, userID}, tcArgs...) + args = append(args, maxDepth) } rows, err := tx.QueryContext(ctx, q, args...) diff --git a/internal/store/pg/mcp_servers.go b/internal/store/pg/mcp_servers.go index acd30c6c..60c6e347 100644 --- a/internal/store/pg/mcp_servers.go +++ b/internal/store/pg/mcp_servers.go @@ -48,30 +48,56 @@ func (s *PGMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServe srv.UpdatedAt = now encHeaders := s.encryptJSONB(jsonOrEmpty(srv.Headers)) encEnv := s.encryptJSONB(jsonOrEmpty(srv.Env)) + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env, - api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command), jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv, nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec, - jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, + jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID, ) return err } func (s *PGMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) { + if store.IsCrossTenant(ctx) { + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE id = $1`, id)) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } return s.scanServer(s.db.QueryRowContext(ctx, `SELECT id, name, display_name, transport, command, args, url, headers, env, api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at - FROM mcp_servers WHERE id = $1`, id)) + FROM mcp_servers WHERE id = $1 AND tenant_id = $2`, id, tenantID)) } func (s *PGMCPServerStore) GetServerByName(ctx context.Context, name string) (*store.MCPServerData, error) { + if store.IsCrossTenant(ctx) { + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE name = $1`, name)) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } return s.scanServer(s.db.QueryRowContext(ctx, `SELECT id, name, display_name, transport, command, args, url, headers, env, api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at - FROM mcp_servers WHERE name = $1`, name)) + FROM mcp_servers WHERE name = $1 AND tenant_id = $2`, name, tenantID)) } func (s *PGMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error) { @@ -108,10 +134,20 @@ func (s *PGMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error } func (s *PGMCPServerStore) ListServers(ctx context.Context) ([]store.MCPServerData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, display_name, transport, command, args, url, headers, env, + query := `SELECT id, name, display_name, transport, command, args, url, headers, env, api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at - FROM mcp_servers ORDER BY name`) + FROM mcp_servers` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return []store.MCPServerData{}, nil + } + query += ` WHERE tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } @@ -178,11 +214,26 @@ func (s *PGMCPServerStore) UpdateServer(ctx context.Context, id uuid.UUID, updat } } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "mcp_servers", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "mcp_servers", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "mcp_servers", updates, id, tid) } func (s *PGMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1 AND tenant_id = $2", id, tid) return err } diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index 72adca4d..2484da13 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" @@ -23,29 +24,39 @@ func (s *PGMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGr } g.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, tool_deny = EXCLUDED.tool_deny, config_overrides = EXCLUDED.config_overrides, granted_by = EXCLUDED.granted_by`, g.ID, g.ServerID, g.AgentID, g.Enabled, jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), - g.GrantedBy, g.CreatedAt, + g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2", serverID, agentID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2"+tClause, + append([]any{serverID, agentID}, tArgs...)...) return err } func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at - FROM mcp_agent_grants WHERE agent_id = $1`, agentID) + FROM mcp_agent_grants WHERE agent_id = $1`+tClause, + append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -64,11 +75,16 @@ func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUI } func (s *PGMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, server_id, agent_id, enabled, COALESCE(tool_allow, '[]'::jsonb), COALESCE(tool_deny, '[]'::jsonb), COALESCE(config_overrides, '{}'::jsonb), granted_by, created_at - FROM mcp_agent_grants WHERE server_id = $1 ORDER BY created_at`, serverID) + FROM mcp_agent_grants WHERE server_id = $1`+tClause+` ORDER BY created_at`, + append([]any{serverID}, tArgs...)...) if err != nil { return nil, err } @@ -89,8 +105,13 @@ func (s *PGMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.U // --- Counts --- func (s *PGMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - `SELECT server_id, COUNT(*) FROM mcp_agent_grants GROUP BY server_id`) + `SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`, + tArgs...) if err != nil { return nil, err } @@ -122,27 +143,36 @@ func (s *PGMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGran } g.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, tool_deny = EXCLUDED.tool_deny, granted_by = EXCLUDED.granted_by`, g.ID, g.ServerID, g.UserID, g.Enabled, jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), - g.GrantedBy, g.CreatedAt, + g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2", serverID, userID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2"+tClause, + append([]any{serverID, userID}, tArgs...)...) return err } // --- Resolution --- func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at, @@ -151,8 +181,9 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = $1 AND mag.enabled = true LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = $2 WHERE ms.enabled = true - AND (mug.id IS NULL OR mug.enabled = true)`, - agentID, userID) + AND (mug.id IS NULL OR mug.enabled = true)`+ + strings.Replace(tClause, "tenant_id", "ms.tenant_id", 1), + append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err } @@ -213,20 +244,25 @@ func (s *PGMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAcce req.Status = "pending" req.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID), req.Scope, req.Status, nilStr(req.Reason), - jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, + jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, reviewed_by, reviewed_at, review_note, created_at - FROM mcp_access_requests WHERE status = 'pending' ORDER BY created_at`) + FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`, + tArgs...) if err != nil { return nil, err } @@ -266,9 +302,14 @@ func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUI var req store.MCPAccessRequest var agentID *uuid.UUID var userID *string + tClause, tArgs, err2 := tenantClauseN(ctx, 2) + if err2 != nil { + return err2 + } err = tx.QueryRowContext(ctx, `SELECT id, server_id, agent_id, user_id, scope, status, tool_allow - FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`, requestID, + FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`+tClause, + append([]any{requestID}, tArgs...)..., ).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow) if err != nil { return fmt.Errorf("request not found or not pending: %w", err) @@ -297,20 +338,20 @@ func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUI return fmt.Errorf("agent_id required for agent scope") } _, err = tx.ExecContext(ctx, - `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at) - VALUES ($1,$2,$3,true,$4,$5,$6) + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,true,$4,$5,$6,$7) ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, + store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx), ) case "user": if userID == nil || *userID == "" { return fmt.Errorf("user_id required for user scope") } _, err = tx.ExecContext(ctx, - `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at) - VALUES ($1,$2,$3,true,$4,$5,$6) + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,true,$4,$5,$6,$7) ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, + store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx), ) default: return fmt.Errorf("unknown scope: %s", req.Scope) diff --git a/internal/store/pg/mcp_user_credentials.go b/internal/store/pg/mcp_user_credentials.go new file mode 100644 index 00000000..06bda59c --- /dev/null +++ b/internal/store/pg/mcp_user_credentials.go @@ -0,0 +1,100 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetUserCredentials returns per-user credential overrides for an MCP server. +// Returns (nil, nil) if no per-user credentials exist. +func (s *PGMCPServerStore) GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*store.MCPUserCredentials, error) { + tid := tenantIDForInsert(ctx) + var apiKey sql.NullString + var headersEnc, envEnc []byte + + err := s.db.QueryRowContext(ctx, + `SELECT api_key, headers, env FROM mcp_user_credentials + WHERE server_id = $1 AND user_id = $2 AND tenant_id = $3`, + serverID, userID, tid, + ).Scan(&apiKey, &headersEnc, &envEnc) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + creds := &store.MCPUserCredentials{} + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + dec := s.decryptJSONB(headersEnc) + json.Unmarshal(dec, &creds.Headers) + } + if len(envEnc) > 0 { + dec := s.decryptJSONB(envEnc) + json.Unmarshal(dec, &creds.Env) + } + + return creds, nil +} + +// SetUserCredentials creates or updates per-user MCP credentials. +func (s *PGMCPServerStore) SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds store.MCPUserCredentials) error { + tid := tenantIDForInsert(ctx) + + var apiKeyEnc sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp user api_key: %w", err) + } + apiKeyEnc = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKeyEnc = sql.NullString{String: creds.APIKey, Valid: true} + } + + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSONB(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSONB(raw) + } + + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_user_credentials (id, server_id, user_id, api_key, headers, env, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT (server_id, user_id, tenant_id) DO UPDATE SET + api_key = $4, headers = $5, env = $6, updated_at = $8`, + uuid.Must(uuid.NewV7()), serverID, userID, apiKeyEnc, headersEnc, envEnc, tid, now, + ) + return err +} + +// DeleteUserCredentials removes per-user MCP credentials. +func (s *PGMCPServerStore) DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error { + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_user_credentials WHERE server_id = $1 AND user_id = $2 AND tenant_id = $3`, + serverID, userID, tid, + ) + return err +} diff --git a/internal/store/pg/memory_admin.go b/internal/store/pg/memory_admin.go index 2adf19db..45764dc2 100644 --- a/internal/store/pg/memory_admin.go +++ b/internal/store/pg/memory_admin.go @@ -9,10 +9,20 @@ import ( // ListAllDocumentsGlobal returns all documents across all agents (for admin overview). func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) { + var whereClause string + var args []any + if !store.IsCrossTenant(ctx) { + tid, err := requireTenantID(ctx) + if err != nil { + return nil, err + } + whereClause = "WHERE tenant_id = $1" + args = []any{tid} + } rows, err := s.db.QueryContext(ctx, `SELECT agent_id, path, hash, user_id, updated_at - FROM memory_documents - ORDER BY updated_at DESC`) + FROM memory_documents `+whereClause+` + ORDER BY updated_at DESC`, args...) if err != nil { return nil, err } @@ -43,10 +53,14 @@ func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.Doc // ListAllDocuments returns all documents for an agent across all users (global + personal). func (s *PGMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) { aid := mustParseUUID(agentID) + tc, tcArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT agent_id, path, hash, user_id, updated_at - FROM memory_documents WHERE agent_id = $1 - ORDER BY updated_at DESC`, aid) + FROM memory_documents WHERE agent_id = $1`+tc+` + ORDER BY updated_at DESC`, append([]any{aid}, tcArgs...)...) if err != nil { return nil, err } @@ -81,23 +95,31 @@ func (s *PGMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, var q string var args []any if userID == "" { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at, COUNT(c.id) AS chunk_count, COUNT(c.embedding) AS embedded_count FROM memory_documents d LEFT JOIN memory_chunks c ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL` + tc + ` GROUP BY d.id` - args = []any{aid, path} + args = append([]any{aid, path}, tcArgs...) } else { + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at, COUNT(c.id) AS chunk_count, COUNT(c.embedding) AS embedded_count FROM memory_documents d LEFT JOIN memory_chunks c ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3 + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3` + tc + ` GROUP BY d.id` - args = []any{aid, path, userID} + args = append([]any{aid, path, userID}, tcArgs...) } var detail store.DocumentDetail @@ -126,23 +148,31 @@ func (s *PGMemoryStore) ListChunks(ctx context.Context, agentID, userID, path st var q string var args []any if userID == "" { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } q = `SELECT c.id, c.start_line, c.end_line, c.text AS text_preview, (c.embedding IS NOT NULL) AS has_embedding FROM memory_chunks c JOIN memory_documents d ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL` + tc + ` ORDER BY c.start_line` - args = []any{aid, path} + args = append([]any{aid, path}, tcArgs...) } else { + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } q = `SELECT c.id, c.start_line, c.end_line, c.text AS text_preview, (c.embedding IS NOT NULL) AS has_embedding FROM memory_chunks c JOIN memory_documents d ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3 + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3` + tc + ` ORDER BY c.start_line` - args = []any{aid, path, userID} + args = append([]any{aid, path, userID}, tcArgs...) } rows, err := s.db.QueryContext(ctx, q, args...) diff --git a/internal/store/pg/memory_docs.go b/internal/store/pg/memory_docs.go index 846216a6..c06f551b 100644 --- a/internal/store/pg/memory_docs.go +++ b/internal/store/pg/memory_docs.go @@ -48,13 +48,21 @@ func (s *PGMemoryStore) GetDocument(ctx context.Context, agentID, userID, path s var err error if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return "", tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL", - aid, path).Scan(&content) + "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...).Scan(&content) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return "", tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3", - aid, path, userID).Scan(&content) + "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc, + append([]any{aid, path, userID}, tcArgs...)...).Scan(&content) } if err != nil { return "", err @@ -67,6 +75,7 @@ func (s *PGMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, hash := memory.ContentHash(content) id := uuid.Must(uuid.NewV7()) now := time.Now() + tid := tenantIDForInsert(ctx) var uid *string if userID != "" { @@ -74,11 +83,11 @@ func (s *PGMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, } _, err := s.db.ExecContext(ctx, - `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, tenant_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (agent_id, COALESCE(user_id, ''), path) - DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, updated_at = EXCLUDED.updated_at`, - id, aid, uid, path, content, hash, now, + DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at`, + id, aid, uid, path, content, hash, tid, now, ) return err } @@ -88,13 +97,21 @@ func (s *PGMemoryStore) DeleteDocument(ctx context.Context, agentID, userID, pat var res sql.Result var err error if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return tcErr + } res, err = s.db.ExecContext(ctx, - "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL", - aid, path) + "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return tcErr + } res, err = s.db.ExecContext(ctx, - "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3", - aid, path, userID) + "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc, + append([]any{aid, path, userID}, tcArgs...)...) } if err != nil { return err @@ -112,11 +129,21 @@ func (s *PGMemoryStore) ListDocuments(ctx context.Context, agentID, userID strin var rows *sql.Rows var err error if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 2) + if tcErr != nil { + return nil, tcErr + } rows, err = s.db.QueryContext(ctx, - "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL", aid) + "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL"+tc, + append([]any{aid}, tcArgs...)...) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return nil, tcErr + } rows, err = s.db.QueryContext(ctx, - "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)", aid, userID) + "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)"+tc, + append([]any{aid, userID}, tcArgs...)...) } if err != nil { return nil, err @@ -157,13 +184,21 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path // Get document ID var docID uuid.UUID if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL", - aid, path).Scan(&docID) + "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...).Scan(&docID) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3", - aid, path, userID).Scan(&docID) + "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc, + append([]any{aid, path, userID}, tcArgs...)...).Scan(&docID) } if err != nil { return err @@ -268,6 +303,7 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path } // Insert chunks + tid := tenantIDForInsert(ctx) for i, tc := range chunks { hash := memory.ContentHash(tc.Text) chunkID := uuid.Must(uuid.NewV7()) @@ -281,17 +317,17 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path if embeddings != nil && i < len(embeddings) && embeddings[i] != nil { // Insert with embedding via raw SQL (pgvector) s.db.ExecContext(ctx, - `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, embedding, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11)`, + `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, embedding, tenant_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12)`, chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, - vectorToString(embeddings[i]), now, + vectorToString(embeddings[i]), tid, now, ) } else { s.db.ExecContext(ctx, - `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, tenant_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT DO NOTHING`, - chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, now, + chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, tid, now, ) } } diff --git a/internal/store/pg/memory_embedding_cache.go b/internal/store/pg/memory_embedding_cache.go index a0e2070e..e43e04a4 100644 --- a/internal/store/pg/memory_embedding_cache.go +++ b/internal/store/pg/memory_embedding_cache.go @@ -67,6 +67,7 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed } now := time.Now() + tenantID := tenantIDForInsert(ctx) // Process in batches of 100 to avoid exceeding max query params const batchSize = 100 @@ -78,16 +79,16 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed batch := entries[start:end] var sb strings.Builder - sb.WriteString(`INSERT INTO embedding_cache (hash, provider, model, embedding, dims, created_at, updated_at) VALUES `) - args := make([]any, 0, len(batch)*6) + sb.WriteString(`INSERT INTO embedding_cache (hash, provider, model, embedding, dims, created_at, updated_at, tenant_id) VALUES `) + args := make([]any, 0, len(batch)*7) for i, e := range batch { if i > 0 { sb.WriteByte(',') } - base := i * 6 - fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d::vector,$%d,$%d,$%d)", - base+1, base+2, base+3, base+4, base+5, base+6, base+6) - args = append(args, e.Hash, provider, model, vectorToString(e.Embedding), len(e.Embedding), now) + base := i * 7 + fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d::vector,$%d,$%d,$%d,$%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+6, base+7) + args = append(args, e.Hash, provider, model, vectorToString(e.Embedding), len(e.Embedding), now, tenantID) } sb.WriteString(` ON CONFLICT (hash, provider, model) DO UPDATE SET embedding = EXCLUDED.embedding, dims = EXCLUDED.dims, updated_at = EXCLUDED.updated_at`) diff --git a/internal/store/pg/memory_search.go b/internal/store/pg/memory_search.go index aec71906..6d55585c 100644 --- a/internal/store/pg/memory_search.go +++ b/internal/store/pg/memory_search.go @@ -2,6 +2,7 @@ package pg import ( "context" + "fmt" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -81,21 +82,37 @@ func (s *PGMemoryStore) ftsSearch(ctx context.Context, query string, agentID any var args []any if userID != "" { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=query, $2=agentID, $3=query, $4=userID + // tenant clause appended at $5 (if filtered), then LIMIT at $5 or $6 + tc, tcArgs, err := tenantClauseN(ctx, 5) + if err != nil { + return nil, err + } + limitN := 5 + len(tcArgs) + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM memory_chunks WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3) - AND (user_id IS NULL OR user_id = $4) - ORDER BY score DESC LIMIT $5` - args = []any{query, agentID, query, userID, limit} + AND (user_id IS NULL OR user_id = $4)%s + ORDER BY score DESC LIMIT $%d`, tc, limitN) + args = append([]any{query, agentID, query, userID}, tcArgs...) + args = append(args, limit) } else { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=query, $2=agentID, $3=query + // tenant clause at $4 (if filtered), then LIMIT at $4 or $5 + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } + limitN := 4 + len(tcArgs) + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM memory_chunks WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3) - AND user_id IS NULL - ORDER BY score DESC LIMIT $4` - args = []any{query, agentID, query, limit} + AND user_id IS NULL%s + ORDER BY score DESC LIMIT $%d`, tc, limitN) + args = append([]any{query, agentID, query}, tcArgs...) + args = append(args, limit) } rows, err := s.db.QueryContext(ctx, q, args...) @@ -120,21 +137,39 @@ func (s *PGMemoryStore) vectorSearch(ctx context.Context, embedding []float32, a var args []any if userID != "" { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=vec, $2=agentID, $3=userID + // tenant clause at $4, then ORDER vec at $4+len(tcArgs), LIMIT after + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } + orderN := 4 + len(tcArgs) + limitN := orderN + 1 + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, 1 - (embedding <=> $1::vector) AS score FROM memory_chunks WHERE agent_id = $2 AND embedding IS NOT NULL - AND (user_id IS NULL OR user_id = $3) - ORDER BY embedding <=> $4::vector LIMIT $5` - args = []any{vecStr, agentID, userID, vecStr, limit} + AND (user_id IS NULL OR user_id = $3)%s + ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN) + args = append([]any{vecStr, agentID, userID}, tcArgs...) + args = append(args, vecStr, limit) } else { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=vec, $2=agentID + // tenant clause at $3, then ORDER vec at $3+len(tcArgs), LIMIT after + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } + orderN := 3 + len(tcArgs) + limitN := orderN + 1 + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, 1 - (embedding <=> $1::vector) AS score FROM memory_chunks WHERE agent_id = $2 AND embedding IS NOT NULL - AND user_id IS NULL - ORDER BY embedding <=> $3::vector LIMIT $4` - args = []any{vecStr, agentID, vecStr, limit} + AND user_id IS NULL%s + ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN) + args = append([]any{vecStr, agentID}, tcArgs...) + args = append(args, vecStr, limit) } rows, err := s.db.QueryContext(ctx, q, args...) diff --git a/internal/store/pg/pairing.go b/internal/store/pg/pairing.go index dc040b3b..aedde459 100644 --- a/internal/store/pg/pairing.go +++ b/internal/store/pg/pairing.go @@ -1,6 +1,7 @@ package pg import ( + "context" "crypto/rand" "database/sql" "encoding/json" @@ -35,20 +36,22 @@ func (s *PGPairingStore) SetOnRequest(cb func(code, senderID, channel, chatID st s.onRequest = cb } -func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) { - // Prune expired - s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) +func (s *PGPairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) { + tid := tenantIDForInsert(ctx) - // Check max pending + // Prune expired + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) + + // Check max pending (per tenant) var count int64 - s.db.QueryRow("SELECT COUNT(*) FROM pairing_requests WHERE account_id = $1", accountID).Scan(&count) + s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pairing_requests WHERE account_id = $1 AND tenant_id = $2", accountID, tid).Scan(&count) if count >= maxPendingPerAccount { return "", fmt.Errorf("max pending pairing requests (%d) exceeded", maxPendingPerAccount) } - // Check existing + // Check existing (per tenant) var existingCode string - err := s.db.QueryRow("SELECT code FROM pairing_requests WHERE sender_id = $1 AND channel = $2", senderID, channel).Scan(&existingCode) + err := s.db.QueryRowContext(ctx, "SELECT code FROM pairing_requests WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3", senderID, channel, tid).Scan(&existingCode) if err == nil { return existingCode, nil } @@ -60,10 +63,10 @@ func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID str code := generatePairingCode() now := time.Now() - _, err = s.db.Exec( - `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, + _, err = s.db.ExecContext(ctx, + `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid, ) if err != nil { return "", fmt.Errorf("create pairing request: %w", err) @@ -74,30 +77,34 @@ func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID str return code, nil } -func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedDeviceData, error) { +// ApprovePairing looks up by code (globally unique random token) and creates paired device. +// The approver's tenant context determines paired_devices.tenant_id. +func (s *PGPairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) { // Prune expired - s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) var reqID uuid.UUID var senderID, channel, chatID string var metaJSON []byte - err := s.db.QueryRow( - "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}') FROM pairing_requests WHERE code = $1", code, - ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON) + var reqTenantID uuid.UUID + // Code lookup is cross-tenant (random token, approver is WS/HTTP user) + err := s.db.QueryRowContext(ctx, + "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}'), tenant_id FROM pairing_requests WHERE code = $1", code, + ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON, &reqTenantID) if err != nil { return nil, fmt.Errorf("pairing code %s not found or expired", code) } // Remove from pending - s.db.Exec("DELETE FROM pairing_requests WHERE id = $1", reqID) + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE id = $1", reqID) - // Add to paired (with expiry for defense-in-depth) + // Add to paired — use the request's tenant (the channel that initiated pairing) now := time.Now() expiresAt := now.Add(pairedDeviceTTL) - _, err = s.db.Exec( - `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, + _, err = s.db.ExecContext(ctx, + `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, reqTenantID, ) if err != nil { return nil, fmt.Errorf("create paired device: %w", err) @@ -118,8 +125,9 @@ func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedD }, nil } -func (s *PGPairingStore) DenyPairing(code string) error { - result, err := s.db.Exec("DELETE FROM pairing_requests WHERE code = $1", code) +func (s *PGPairingStore) DenyPairing(ctx context.Context, code string) error { + // Code lookup is cross-tenant (random token) + result, err := s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE code = $1", code) if err != nil { return err } @@ -130,8 +138,9 @@ func (s *PGPairingStore) DenyPairing(code string) error { return nil } -func (s *PGPairingStore) RevokePairing(senderID, channel string) error { - result, err := s.db.Exec("DELETE FROM paired_devices WHERE sender_id = $1 AND channel = $2", senderID, channel) +func (s *PGPairingStore) RevokePairing(ctx context.Context, senderID, channel string) error { + tid := tenantIDForInsert(ctx) + result, err := s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3", senderID, channel, tid) if err != nil { return err } @@ -142,11 +151,12 @@ func (s *PGPairingStore) RevokePairing(senderID, channel string) error { return nil } -func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) { +func (s *PGPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) { + tid := tenantIDForInsert(ctx) var count int64 - err := s.db.QueryRow( - "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND (expires_at IS NULL OR expires_at > NOW())", - senderID, channel, + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3 AND (expires_at IS NULL OR expires_at > NOW())", + senderID, channel, tid, ).Scan(&count) if err != nil { return false, fmt.Errorf("pairing check query: %w", err) @@ -154,12 +164,15 @@ func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) { return count > 0, nil } -func (s *PGPairingStore) ListPending() []store.PairingRequestData { - // Prune expired - s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) +func (s *PGPairingStore) ListPending(ctx context.Context) []store.PairingRequestData { + tid := tenantIDForInsert(ctx) - rows, err := s.db.Query( - "SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}') FROM pairing_requests ORDER BY created_at DESC") + // Prune expired + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) + + rows, err := s.db.QueryContext(ctx, + `SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}') + FROM pairing_requests WHERE tenant_id = $1 ORDER BY created_at DESC`, tid) if err != nil { return nil } @@ -186,11 +199,15 @@ func (s *PGPairingStore) ListPending() []store.PairingRequestData { return result } -func (s *PGPairingStore) ListPaired() []store.PairedDeviceData { - // Prune expired paired devices - s.db.Exec("DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()") +func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData { + tid := tenantIDForInsert(ctx) - rows, err := s.db.Query("SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') FROM paired_devices ORDER BY paired_at DESC") + // Prune expired paired devices + s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()") + + rows, err := s.db.QueryContext(ctx, + `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') + FROM paired_devices WHERE tenant_id = $1 ORDER BY paired_at DESC`, tid) if err != nil { return nil } diff --git a/internal/store/pg/pending_message_store.go b/internal/store/pg/pending_message_store.go index 4736a480..ec340fa4 100644 --- a/internal/store/pg/pending_message_store.go +++ b/internal/store/pg/pending_message_store.go @@ -27,25 +27,26 @@ func (s *PGPendingMessageStore) AppendBatch(ctx context.Context, msgs []store.Pe return nil } - // Build multi-row INSERT: VALUES ($1,$2,...,$10), ($11,$12,...,$20), ... - const cols = 10 + // Build multi-row INSERT: VALUES ($1,$2,...,$11), ($12,$13,...,$22), ... + const cols = 11 placeholders := make([]string, len(msgs)) args := make([]any, 0, len(msgs)*cols) now := time.Now() + tid := tenantIDForInsert(ctx) for i := range msgs { if msgs[i].ID == uuid.Nil { msgs[i].ID = uuid.Must(uuid.NewV7()) } base := i * cols - placeholders[i] = fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)", - base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10) + placeholders[i] = fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10, base+11) args = append(args, msgs[i].ID, msgs[i].ChannelName, msgs[i].HistoryKey, - msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now) + msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now, tid) } _, err := s.db.ExecContext(ctx, - `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at) + `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id) VALUES `+strings.Join(placeholders, ","), args..., ) @@ -53,12 +54,16 @@ func (s *PGPendingMessageStore) AppendBatch(ctx context.Context, msgs []store.Pe } func (s *PGPendingMessageStore) ListByKey(ctx context.Context, channelName, historyKey string) ([]store.PendingMessage, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at FROM channel_pending_messages - WHERE channel_name = $1 AND history_key = $2 + WHERE channel_name = $1 AND history_key = $2`+tClause+` ORDER BY created_at ASC`, - channelName, historyKey, + append([]any{channelName, historyKey}, tArgs...)..., ) if err != nil { return nil, err @@ -77,9 +82,13 @@ func (s *PGPendingMessageStore) ListByKey(ctx context.Context, channelName, hist } func (s *PGPendingMessageStore) DeleteByKey(ctx context.Context, channelName, historyKey string) error { - _, err := s.db.ExecContext(ctx, - `DELETE FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`, - channelName, historyKey, + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`+tClause, + append([]any{channelName, historyKey}, tArgs...)..., ) return err } @@ -123,9 +132,9 @@ func (s *PGPendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UU } now := time.Now() _, err = tx.ExecContext(ctx, - `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, + `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, tenantIDForInsert(ctx), ) if err != nil { return fmt.Errorf("compact insert summary: %w", err) @@ -136,9 +145,10 @@ func (s *PGPendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UU func (s *PGPendingMessageStore) DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error) { cutoff := time.Now().Add(-olderThan) + tid := tenantIDForInsert(ctx) result, err := s.db.ExecContext(ctx, - `DELETE FROM channel_pending_messages WHERE updated_at < $1`, - cutoff, + `DELETE FROM channel_pending_messages WHERE updated_at < $1 AND tenant_id = $2`, + cutoff, tid, ) if err != nil { return 0, err @@ -147,6 +157,14 @@ func (s *PGPendingMessageStore) DeleteStale(ctx context.Context, olderThan time. } func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.PendingMessageGroup, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } + var where string + if tClause != "" { + where = ` WHERE m.tenant_id = $1` + } rows, err := s.db.QueryContext(ctx, `SELECT channel_name, history_key, COUNT(*) AS message_count, @@ -165,9 +183,10 @@ func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.Pending ) ) AS has_summary, MAX(created_at) AS last_activity - FROM channel_pending_messages m + FROM channel_pending_messages m`+where+` GROUP BY channel_name, history_key ORDER BY last_activity DESC`, + tArgs..., ) if err != nil { return nil, err @@ -186,16 +205,30 @@ func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.Pending } func (s *PGPendingMessageStore) CountAll(ctx context.Context) (int64, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return 0, err + } var count int64 - err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_pending_messages`).Scan(&count) + var query string + if tClause != "" { + query = `SELECT COUNT(*) FROM channel_pending_messages WHERE tenant_id = $1` + } else { + query = `SELECT COUNT(*) FROM channel_pending_messages` + } + err = s.db.QueryRowContext(ctx, query, tArgs...).Scan(&count) return count, err } func (s *PGPendingMessageStore) CountByKey(ctx context.Context, channelName, historyKey string) (int, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return 0, err + } var count int - err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`, - channelName, historyKey, + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`+tClause, + append([]any{channelName, historyKey}, tArgs...)..., ).Scan(&count) return count, err } @@ -216,11 +249,22 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [ args = append(args, g.ChannelName, g.HistoryKey) } + tenantFilter := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + argIdx := len(args) + 1 + tenantFilter = fmt.Sprintf(" AND tenant_id = $%d", argIdx) + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, - `SELECT session_key, metadata->>'chat_title' - FROM sessions - WHERE metadata->>'chat_title' != '' - AND (`+strings.Join(conditions, " OR ")+`)`, + "SELECT session_key, metadata->>'chat_title'"+ + " FROM sessions"+ + " WHERE metadata->>'chat_title' != ''"+ + " AND ("+strings.Join(conditions, " OR ")+")"+tenantFilter, args..., ) if err != nil { diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go index 4db4aa26..18b10c35 100644 --- a/internal/store/pg/providers.go +++ b/internal/store/pg/providers.go @@ -50,21 +50,28 @@ func (s *PGProviderStore) CreateProvider(ctx context.Context, p *store.LLMProvid now := time.Now() p.CreatedAt = now p.UpdatedAt = now + tid := tenantIDForInsert(ctx) + p.TenantID = tid _, err := s.db.ExecContext(ctx, - `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, + `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, tid, ) return err } func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } var p store.LLMProviderData var apiKey string - err := s.db.QueryRowContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at - FROM llm_providers WHERE id = $1`, id, - ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt) + err = s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE id = $1`+tClause, + append([]any{id}, tArgs...)..., + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID) if err != nil { return nil, fmt.Errorf("provider not found: %s", id) } @@ -73,12 +80,17 @@ func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store } func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } var p store.LLMProviderData var apiKey string - err := s.db.QueryRowContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at - FROM llm_providers WHERE name = $1`, name, - ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt) + err = s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE name = $1`+tClause, + append([]any{name}, tArgs...)..., + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID) if err != nil { return nil, fmt.Errorf("provider not found: %s", name) } @@ -87,9 +99,13 @@ func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (* } func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at - FROM llm_providers ORDER BY name`) + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } + q := `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE true` + tClause + ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, q, tArgs...) if err != nil { return nil, err } @@ -99,7 +115,7 @@ func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProvide for rows.Next() { var p store.LLMProviderData var apiKey string - if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID); err != nil { continue } p.APIKey = s.decryptKey(apiKey, p.Name) @@ -118,11 +134,25 @@ func (s *PGProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, upda updates["api_key"] = encrypted } } - return execMapUpdate(ctx, s.db, "llm_providers", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "llm_providers", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + return execMapUpdateWhereTenant(ctx, s.db, "llm_providers", updates, id, tid) } func (s *PGProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM llm_providers WHERE id = $1", id) + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM llm_providers WHERE id = $1"+tClause, + append([]any{id}, tArgs...)..., + ) return err } diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 5ed8e6eb..462d173b 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -50,23 +50,37 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) b.CreatedAt = now b.UpdatedAt = now + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env, - deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description, envBytes, jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose), b.TimeoutSeconds, b.Tips, nilUUID(b.AgentID), b.Enabled, - b.CreatedBy, now, now, + b.CreatedBy, now, now, tenantID, ) return err } func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id) + return s.scanRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } row := s.db.QueryRowContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id) + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2`, id, tenantID) return s.scanRow(row) } @@ -179,17 +193,42 @@ func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map } } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "secure_cli_binaries", updates, id, tid) } func (s *PGSecureCLIStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2", id, tid) return err } func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries ORDER BY binary_name, agent_id NULLS LAST`) + query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` WHERE tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY binary_name, agent_id NULLS LAST` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } @@ -197,10 +236,24 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e } func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) { + if store.IsCrossTenant(ctx) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true + ORDER BY binary_name, agent_id NULLS LAST`, agentID) + if err != nil { + return nil, err + } + return s.scanRows(rows) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } rows, err := s.db.QueryContext(ctx, `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true - ORDER BY binary_name, agent_id NULLS LAST`, agentID) + WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true AND tenant_id = $2 + ORDER BY binary_name, agent_id NULLS LAST`, agentID, tenantID) if err != nil { return nil, err } @@ -212,15 +265,35 @@ func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ( func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*store.SecureCLIBinary, error) { var row *sql.Row if agentID != nil { - row = s.db.QueryRowContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true - ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID) + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true + ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, nil + } + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true AND tenant_id = $3 + ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID, tid) + } } else { - row = s.db.QueryRowContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true - LIMIT 1`, binaryName) + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true LIMIT 1`, binaryName) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, nil + } + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true AND tenant_id = $2 LIMIT 1`, binaryName, tid) + } } b, err := s.scanRow(row) if err == sql.ErrNoRows { @@ -230,9 +303,18 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string } func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE enabled = true ORDER BY binary_name`) + query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` AND tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY binary_name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } diff --git a/internal/store/pg/sessions.go b/internal/store/pg/sessions.go index d7a53730..3ea7c243 100644 --- a/internal/store/pg/sessions.go +++ b/internal/store/pg/sessions.go @@ -1,6 +1,7 @@ package pg import ( + "context" "database/sql" "encoding/json" "log/slog" @@ -58,17 +59,27 @@ func (s *PGSessionStore) migrateLegacyWSKeys() { } } -func (s *PGSessionStore) GetOrCreate(key string) *store.SessionData { +// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions. +// Two tenants with the same agent_key produce different cache keys. +func sessionCacheKey(ctx context.Context, key string) string { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + return tid.String() + ":" + key +} + +func (s *PGSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData { s.mu.Lock() defer s.mu.Unlock() - if cached, ok := s.cache[key]; ok { + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return cached } - data := s.loadFromDB(key) + data := s.loadFromDB(ctx, key) if data != nil { - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data return data } @@ -89,30 +100,54 @@ func (s *PGSessionStore) GetOrCreate(key string) *store.SessionData { data.TeamID = teamID } } - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data msgsJSON, _ := json.Marshal([]providers.Message{}) s.db.Exec( - `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id) - VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (session_key) DO NOTHING`, - uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx), ) return data } -func (s *PGSessionStore) AddMessage(key string, msg providers.Message) { +// Get returns the session if it exists (cache or DB), nil otherwise. Never creates. +func (s *PGSessionStore) Get(ctx context.Context, key string) *store.SessionData { + s.mu.RLock() + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + s.mu.RUnlock() + return cached + } + s.mu.RUnlock() + s.mu.Lock() defer s.mu.Unlock() - data := s.getOrInit(key) + // Double-check after acquiring write lock + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return cached + } + + data := s.loadFromDB(ctx, key) + if data != nil { + s.cache[sessionCacheKey(ctx, key)] = data + } + return data +} + +func (s *PGSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) { + s.mu.Lock() + defer s.mu.Unlock() + + data := s.getOrInit(ctx, key) data.Messages = append(data.Messages, msg) data.Updated = time.Now() } -func (s *PGSessionStore) GetHistory(key string) []providers.Message { +func (s *PGSessionStore) GetHistory(ctx context.Context, key string) []providers.Message { s.mu.RLock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { msgs := make([]providers.Message, len(data.Messages)) copy(msgs, data.Messages) s.mu.RUnlock() @@ -125,62 +160,62 @@ func (s *PGSessionStore) GetHistory(key string) []providers.Message { defer s.mu.Unlock() // Double-check after acquiring write lock - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { msgs := make([]providers.Message, len(data.Messages)) copy(msgs, data.Messages) return msgs } - data := s.loadFromDB(key) + data := s.loadFromDB(ctx, key) if data == nil { return nil } - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data msgs := make([]providers.Message, len(data.Messages)) copy(msgs, data.Messages) return msgs } -func (s *PGSessionStore) GetSummary(key string) string { +func (s *PGSessionStore) GetSummary(ctx context.Context, key string) string { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.Summary } return "" } -func (s *PGSessionStore) SetSummary(key, summary string) { +func (s *PGSessionStore) SetSummary(ctx context.Context, key, summary string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Summary = summary data.Updated = time.Now() } } -func (s *PGSessionStore) GetLabel(key string) string { +func (s *PGSessionStore) GetLabel(ctx context.Context, key string) string { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.Label } return "" } -func (s *PGSessionStore) SetLabel(key, label string) { +func (s *PGSessionStore) SetLabel(ctx context.Context, key, label string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Label = label data.Updated = time.Now() } } -func (s *PGSessionStore) GetSessionMetadata(key string) map[string]string { +func (s *PGSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok && data.Metadata != nil { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil { out := make(map[string]string, len(data.Metadata)) maps.Copy(out, data.Metadata) return out @@ -188,10 +223,10 @@ func (s *PGSessionStore) GetSessionMetadata(key string) map[string]string { return nil } -func (s *PGSessionStore) SetSessionMetadata(key string, metadata map[string]string) { +func (s *PGSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) { s.mu.Lock() defer s.mu.Unlock() - data := s.getOrInit(key) + data := s.getOrInit(ctx, key) if data.Metadata == nil { data.Metadata = make(map[string]string) } @@ -199,10 +234,10 @@ func (s *PGSessionStore) SetSessionMetadata(key string, metadata map[string]stri data.Updated = time.Now() } -func (s *PGSessionStore) SetAgentInfo(key string, agentUUID uuid.UUID, userID string) { +func (s *PGSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) { s.mu.Lock() defer s.mu.Unlock() - data := s.getOrInit(key) + data := s.getOrInit(ctx, key) if agentUUID != uuid.Nil { data.AgentUUID = agentUUID } @@ -211,10 +246,10 @@ func (s *PGSessionStore) SetAgentInfo(key string, agentUUID uuid.UUID, userID st } } -func (s *PGSessionStore) UpdateMetadata(key, model, provider, channel string) { +func (s *PGSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { if model != "" { data.Model = model } diff --git a/internal/store/pg/sessions_list.go b/internal/store/pg/sessions_list.go index 05e63005..9e6192cc 100644 --- a/internal/store/pg/sessions_list.go +++ b/internal/store/pg/sessions_list.go @@ -1,7 +1,7 @@ package pg import ( - "database/sql" + "context" "encoding/json" "fmt" "strings" @@ -41,6 +41,12 @@ func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, args = append(args, opts.UserID) idx++ } + if opts.TenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("%stenant_id = $%d", prefix, idx)) + args = append(args, opts.TenantID) + idx++ + } + _ = idx // consumed if len(conditions) == 0 { return "", nil @@ -48,17 +54,34 @@ func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, return " WHERE " + strings.Join(conditions, " AND "), args } -func (s *PGSessionStore) List(agentID string) []store.SessionInfo { - var rows *sql.Rows - var err error +func (s *PGSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo { + var conditions []string + var args []any + idx := 1 + if agentID != "" { - prefix := "agent:" + agentID + ":%" - rows, err = s.db.Query( - "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC", prefix) - } else { - rows, err = s.db.Query( - "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions ORDER BY updated_at DESC") + conditions = append(conditions, fmt.Sprintf("session_key LIKE $%d", idx)) + args = append(args, "agent:"+agentID+":%") + idx++ } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", idx)) + args = append(args, tid) + idx++ + } + } + _ = idx + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + + rows, err := s.db.QueryContext(ctx, + "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC", + args...) if err != nil { return nil } @@ -94,7 +117,7 @@ func (s *PGSessionStore) List(agentID string) []store.SessionInfo { return result } -func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionListResult { +func (s *PGSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult { limit := opts.Limit if limit <= 0 { limit = 20 @@ -106,7 +129,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList // Count total var total int countQ := "SELECT COUNT(*) FROM sessions" + where - if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil { + if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil { return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0} } @@ -116,7 +139,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList FROM sessions%s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d`, where, nextIdx, nextIdx+1) selectArgs := append(append([]any{}, whereArgs...), limit, offset) - rows, err := s.db.Query(selectQ, selectArgs...) + rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...) if err != nil { return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total} } @@ -154,7 +177,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList } // ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name). -func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.SessionListRichResult { +func (s *PGSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult { limit := opts.Limit if limit <= 0 { limit = 20 @@ -166,7 +189,7 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session // Count total var total int countQ := "SELECT COUNT(*) FROM sessions s" + where - if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil { + if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil { return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0} } @@ -185,7 +208,7 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session %s ORDER BY s.updated_at DESC LIMIT $%d OFFSET $%d`, richCols, where, nextIdx, nextIdx+1) selectArgs := append(append([]any{}, whereArgs...), limit, offset) - rows, err := s.db.Query(selectQ, selectArgs...) + rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...) if err != nil { return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total} } @@ -238,9 +261,9 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session return store.SessionListRichResult{Sessions: result, Total: total} } -func (s *PGSessionStore) Save(key string) error { +func (s *PGSessionStore) Save(ctx context.Context, key string) error { s.mu.RLock() - data, ok := s.cache[key] + data, ok := s.cache[sessionCacheKey(ctx, key)] if !ok { s.mu.RUnlock() return nil @@ -258,7 +281,7 @@ func (s *PGSessionStore) Save(key string) error { metaJSON, _ = json.Marshal(snapshot.Metadata) } - _, err := s.db.Exec( + _, err := s.db.ExecContext(ctx, `UPDATE sessions SET messages = $1, summary = $2, model = $3, provider = $4, channel = $5, input_tokens = $6, output_tokens = $7, compaction_count = $8, @@ -266,30 +289,33 @@ func (s *PGSessionStore) Save(key string) error { label = $11, spawned_by = $12, spawn_depth = $13, agent_id = $14, user_id = $15, metadata = $16, updated_at = $17, team_id = $18 - WHERE session_key = $19`, + WHERE session_key = $19 AND tenant_id = $20`, msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel), snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount, snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt, nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth, nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), metaJSON, snapshot.Updated, snapshot.TeamID, - key, + key, tenantIDForInsert(ctx), ) return err } -func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) { +func (s *PGSessionStore) LastUsedChannel(ctx context.Context, agentID string) (string, string) { prefix := "agent:" + agentID + ":%" + tid := tenantIDForInsert(ctx) var sessionKey string - err := s.db.QueryRow( + err := s.db.QueryRowContext(ctx, `SELECT session_key FROM sessions WHERE session_key LIKE $1 AND session_key NOT LIKE $2 AND session_key NOT LIKE $3 + AND tenant_id = $4 ORDER BY updated_at DESC LIMIT 1`, prefix, "agent:"+agentID+":cron:%", "agent:"+agentID+":subagent:%", + tid, ).Scan(&sessionKey) if err != nil { return "", "" @@ -303,15 +329,15 @@ func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) { // --- helpers --- -func (s *PGSessionStore) getOrInit(key string) *store.SessionData { - if data, ok := s.cache[key]; ok { +func (s *PGSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data } // Try loading from DB first to avoid overwriting existing messages - data := s.loadFromDB(key) + data := s.loadFromDB(ctx, key) if data != nil { - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data return data } @@ -323,18 +349,18 @@ func (s *PGSessionStore) getOrInit(key string) *store.SessionData { Created: now, Updated: now, } - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data msgsJSON, _ := json.Marshal([]providers.Message{}) s.db.Exec( - `INSERT INTO sessions (id, session_key, messages, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_key) DO NOTHING`, - uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx), ) return data } -func (s *PGSessionStore) loadFromDB(key string) *store.SessionData { +func (s *PGSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData { var sessionKey string var msgsJSON []byte var summary, model, provider, channel, label, spawnedBy, userID *string @@ -345,13 +371,14 @@ func (s *PGSessionStore) loadFromDB(key string) *store.SessionData { var createdAt, updatedAt time.Time var metaJSON *[]byte - err := s.db.QueryRow( + tid := tenantIDForInsert(ctx) + err := s.db.QueryRowContext(ctx, `SELECT session_key, messages, summary, model, provider, channel, input_tokens, output_tokens, compaction_count, memory_flush_compaction_count, memory_flush_at, label, spawned_by, spawn_depth, agent_id, user_id, COALESCE(metadata, '{}'), created_at, updated_at, team_id - FROM sessions WHERE session_key = $1`, key, + FROM sessions WHERE session_key = $1 AND tenant_id = $2`, key, tid, ).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel, &inputTokens, &outputTokens, &compactionCount, &memoryFlushCompactionCount, &memoryFlushAt, diff --git a/internal/store/pg/sessions_ops.go b/internal/store/pg/sessions_ops.go index 5033b6de..f835c871 100644 --- a/internal/store/pg/sessions_ops.go +++ b/internal/store/pg/sessions_ops.go @@ -1,15 +1,16 @@ package pg import ( + "context" "time" "github.com/nextlevelbuilder/goclaw/internal/providers" ) -func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { +func (s *PGSessionStore) TruncateHistory(ctx context.Context, key string, keepLast int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { if keepLast <= 0 { data.Messages = []providers.Message{} } else if len(data.Messages) > keepLast { @@ -19,28 +20,28 @@ func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { } } -func (s *PGSessionStore) SetHistory(key string, msgs []providers.Message) { +func (s *PGSessionStore) SetHistory(ctx context.Context, key string, msgs []providers.Message) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Messages = msgs data.Updated = time.Now() } } -func (s *PGSessionStore) Reset(key string) { +func (s *PGSessionStore) Reset(ctx context.Context, key string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Messages = []providers.Message{} data.Summary = "" data.Updated = time.Now() } } -func (s *PGSessionStore) Delete(key string) error { +func (s *PGSessionStore) Delete(ctx context.Context, key string) error { s.mu.Lock() - delete(s.cache, key) + delete(s.cache, sessionCacheKey(ctx, key)) s.mu.Unlock() // Clean up associated media files before deleting from DB. @@ -48,6 +49,7 @@ func (s *PGSessionStore) Delete(key string) error { s.OnDelete(key) } - _, err := s.db.Exec("DELETE FROM sessions WHERE session_key = $1", key) + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE session_key = $1 AND tenant_id = $2", key, tid) return err } diff --git a/internal/store/pg/sessions_tokens.go b/internal/store/pg/sessions_tokens.go index 2aace97c..45fd7c76 100644 --- a/internal/store/pg/sessions_tokens.go +++ b/internal/store/pg/sessions_tokens.go @@ -1,90 +1,93 @@ package pg -import "time" +import ( + "context" + "time" +) -func (s *PGSessionStore) AccumulateTokens(key string, input, output int64) { +func (s *PGSessionStore) AccumulateTokens(ctx context.Context, key string, input, output int64) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.InputTokens += input data.OutputTokens += output } } -func (s *PGSessionStore) IncrementCompaction(key string) { +func (s *PGSessionStore) IncrementCompaction(ctx context.Context, key string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.CompactionCount++ } } -func (s *PGSessionStore) GetCompactionCount(key string) int { +func (s *PGSessionStore) GetCompactionCount(ctx context.Context, key string) int { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.CompactionCount } return 0 } -func (s *PGSessionStore) GetMemoryFlushCompactionCount(key string) int { +func (s *PGSessionStore) GetMemoryFlushCompactionCount(ctx context.Context, key string) int { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.MemoryFlushCompactionCount } return -1 } -func (s *PGSessionStore) SetMemoryFlushDone(key string) { +func (s *PGSessionStore) SetMemoryFlushDone(ctx context.Context, key string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.MemoryFlushCompactionCount = data.CompactionCount data.MemoryFlushAt = time.Now().UnixMilli() } } -func (s *PGSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) { +func (s *PGSessionStore) SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.SpawnedBy = spawnedBy data.SpawnDepth = depth } } -func (s *PGSessionStore) SetContextWindow(key string, cw int) { +func (s *PGSessionStore) SetContextWindow(ctx context.Context, key string, cw int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.ContextWindow = cw } } -func (s *PGSessionStore) GetContextWindow(key string) int { +func (s *PGSessionStore) GetContextWindow(ctx context.Context, key string) int { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.ContextWindow } return 0 } -func (s *PGSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) { +func (s *PGSessionStore) SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.LastPromptTokens = tokens data.LastMessageCount = msgCount } } -func (s *PGSessionStore) GetLastPromptTokens(key string) (int, int) { +func (s *PGSessionStore) GetLastPromptTokens(ctx context.Context, key string) (int, int) { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.LastPromptTokens, data.LastMessageCount } return 0, 0 diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go index 4b860069..fbb5f84d 100644 --- a/internal/store/pg/skills.go +++ b/internal/store/pg/skills.go @@ -1,6 +1,7 @@ package pg import ( + "context" "database/sql" "encoding/json" "log/slog" @@ -27,22 +28,29 @@ type PGSkillStore struct { cache map[string]*store.SkillInfo version atomic.Int64 - // List cache: cached result of ListSkills() with version + TTL validation - listCache []store.SkillInfo - listVer int64 - listTime time.Time + // List cache: per-tenant cached result of ListSkills() with version + TTL validation. + // Key is tenant UUID; uuid.Nil = cross-tenant (system admin). + listCache map[uuid.UUID]*listCacheEntry ttl time.Duration // Embedding provider for vector-based skill search embProvider store.EmbeddingProvider } +// listCacheEntry holds per-tenant cached skill list with version + TTL. +type listCacheEntry struct { + skills []store.SkillInfo + ver int64 + time time.Time +} + func NewPGSkillStore(db *sql.DB, baseDir string) *PGSkillStore { return &PGSkillStore{ - db: db, - baseDir: baseDir, - cache: make(map[string]*store.SkillInfo), - ttl: defaultSkillsCacheTTL, + db: db, + baseDir: baseDir, + cache: make(map[string]*store.SkillInfo), + listCache: make(map[uuid.UUID]*listCacheEntry), + ttl: defaultSkillsCacheTTL, } } @@ -50,12 +58,17 @@ func (s *PGSkillStore) Version() int64 { return s.version.Load() } func (s *PGSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) } func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} } -func (s *PGSkillStore) ListSkills() []store.SkillInfo { +func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { currentVer := s.version.Load() + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil && !store.IsCrossTenant(ctx) { + tid = store.MasterTenantID + } + // Check per-tenant cache s.mu.RLock() - if s.listCache != nil && s.listVer == currentVer && time.Since(s.listTime) < s.ttl { - result := s.listCache + if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl { + result := entry.skills s.mu.RUnlock() return result } @@ -64,8 +77,19 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { // Cache miss or TTL expired → query DB // Returns active + archived + system skills. Archived skills are shown dimmed in the UI // so admins can see missing deps and re-activate after installing them. - rows, err := s.db.Query( - `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path FROM skills WHERE status IN ('active', 'archived') OR is_system = true ORDER BY name`) + // Tenant filter: system skills visible globally, custom skills scoped to tenant. + var rows *sql.Rows + var err error + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE status IN ('active', 'archived') OR is_system = true ORDER BY name`) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE (status IN ('active', 'archived') OR is_system = true) AND (is_system = true OR tenant_id = $1) + ORDER BY name`, tid) + } if err != nil { return nil } @@ -100,9 +124,7 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { } s.mu.Lock() - s.listCache = result - s.listVer = currentVer - s.listTime = time.Now() + s.listCache[tid] = &listCacheEntry{skills: result, ver: currentVer, time: time.Now()} s.mu.Unlock() return result @@ -110,8 +132,8 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { // ListAllSkills returns all enabled skills regardless of status (for admin operations like rescan-deps). // Disabled skills are excluded — no point scanning or updating them. -func (s *PGSkillStore) ListAllSkills() []store.SkillInfo { - rows, err := s.db.Query( +func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path FROM skills WHERE enabled = true AND status != 'deleted' ORDER BY name`) if err != nil { return nil diff --git a/internal/store/pg/skills_content.go b/internal/store/pg/skills_content.go index e041cdc5..6e157176 100644 --- a/internal/store/pg/skills_content.go +++ b/internal/store/pg/skills_content.go @@ -1,6 +1,7 @@ package pg import ( + "context" "fmt" "os" "regexp" @@ -15,13 +16,22 @@ import ( // skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file. var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`) -func (s *PGSkillStore) LoadSkill(name string) (string, bool) { +func (s *PGSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) { var slug string var version int var filePath *string - err := s.db.QueryRow( - "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'", name, - ).Scan(&slug, &version, &filePath) + // Tenant filter: system skills visible globally, custom skills scoped to tenant. + q := "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'" + args := []any{name} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath) if err != nil { return "", false } @@ -33,14 +43,14 @@ func (s *PGSkillStore) LoadSkill(name string) (string, bool) { return content, true } -func (s *PGSkillStore) LoadForContext(allowList []string) string { - skills := s.FilterSkills(allowList) +func (s *PGSkillStore) LoadForContext(ctx context.Context, allowList []string) string { + skills := s.FilterSkills(ctx, allowList) if len(skills) == 0 { return "" } var parts []string for _, sk := range skills { - content, ok := s.LoadSkill(sk.Name) + content, ok := s.LoadSkill(ctx, sk.Name) if !ok { continue } @@ -60,8 +70,8 @@ func (s *PGSkillStore) LoadForContext(allowList []string) string { return result.String() } -func (s *PGSkillStore) BuildSummary(allowList []string) string { - skills := s.FilterSkills(allowList) +func (s *PGSkillStore) BuildSummary(ctx context.Context, allowList []string) string { + skills := s.FilterSkills(ctx, allowList) if len(skills) == 0 { return "" } @@ -78,7 +88,7 @@ func (s *PGSkillStore) BuildSummary(allowList []string) string { return result.String() } -func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { +func (s *PGSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) { var id uuid.UUID var skillName, slug, visibility string var desc *string @@ -86,9 +96,17 @@ func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { var version int var isSystem bool var filePath *string - err := s.db.QueryRow( - "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'", name, - ).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &filePath) + q := "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'" + args := []any{name} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &filePath) if err != nil { return nil, false } @@ -99,8 +117,8 @@ func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { return &info, true } -func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo { - all := s.ListSkills() +func (s *PGSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo { + all := s.ListSkills(ctx) var filtered []store.SkillInfo if allowList == nil { // No allowList → return all enabled skills (for agent injection) @@ -128,7 +146,8 @@ func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo { // GetSkillByID returns a SkillInfo for any skill by UUID, regardless of status or enabled flag. // Used by admin operations (e.g. toggle) that need full skill info. -func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { +// Tenant filter: system skills visible globally, custom skills scoped to tenant. +func (s *PGSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) { var name, slug, visibility, status string var desc *string var tags []string @@ -136,11 +155,18 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { var isSystem, enabled bool var depsRaw []byte var filePath *string - err := s.db.QueryRow( - `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path - FROM skills WHERE id = $1`, - id, - ).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath) + q := `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE id = $1` + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath) if err != nil { return store.SkillInfo{}, false } @@ -156,10 +182,19 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { // GetSkillOwnerID returns the owner_id for a skill by UUID. // Returns ("", false) if the skill does not exist. -func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) { +func (s *PGSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) { + q := "SELECT owner_id FROM skills WHERE id = $1" + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } var ownerID string - err := s.db.QueryRow("SELECT owner_id FROM skills WHERE id = $1", id).Scan(&ownerID) - if err != nil { + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil { return "", false } return ownerID, true @@ -167,10 +202,19 @@ func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) { // GetSkillOwnerIDBySlug returns the owner_id for a skill by slug. // Returns ("", false) if the skill does not exist or is archived. -func (s *PGSkillStore) GetSkillOwnerIDBySlug(slug string) (string, bool) { +func (s *PGSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) { + q := "SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'" + args := []any{slug} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } var ownerID string - err := s.db.QueryRow("SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'", slug).Scan(&ownerID) - if err != nil { + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil { return "", false } return ownerID, true diff --git a/internal/store/pg/skills_crud.go b/internal/store/pg/skills_crud.go index b6cd7dea..8c79a856 100644 --- a/internal/store/pg/skills_crud.go +++ b/internal/store/pg/skills_crud.go @@ -29,9 +29,9 @@ type SkillCreateParams struct { func (s *PGSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error { id := store.GenNewID() _, err := s.db.Exec( - `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, NOW(), NOW())`, - id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, + `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW())`, + id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, store.MasterTenantID, ) if err == nil { s.BumpVersion() @@ -39,42 +39,68 @@ func (s *PGSkillStore) CreateSkill(name, slug string, description *string, owner return err } -func (s *PGSkillStore) UpdateSkill(id uuid.UUID, updates map[string]any) error { - if err := execMapUpdate(context.Background(), s.db, "skills", id, updates); err != nil { +func (s *PGSkillStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error { + var err error + if store.IsCrossTenant(ctx) { + err = execMapUpdate(ctx, s.db, "skills", id, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } else { + err = execMapUpdateWhereTenant(ctx, s.db, "skills", updates, id, tid) + } + } + if err != nil { return err } s.BumpVersion() return nil } -func (s *PGSkillStore) DeleteSkill(id uuid.UUID) error { +func (s *PGSkillStore) DeleteSkill(ctx context.Context, id uuid.UUID) error { // Reject deletion of system skills var isSystem bool - if err := s.db.QueryRow("SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil { + if err := s.db.QueryRowContext(ctx, "SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil { return fmt.Errorf("check skill: %w", err) } if isSystem { return fmt.Errorf("cannot delete system skill") } - tx, err := s.db.Begin() + // Tenant-scoped delete: only delete skill owned by this tenant. + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + var skillTenantID uuid.UUID + if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM skills WHERE id = $1", id).Scan(&skillTenantID); err != nil { + return fmt.Errorf("skill not found") + } + if skillTenantID != tid { + return fmt.Errorf("skill not found") + } + } + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Cascade: remove all agent grants for this skill - if _, err := tx.Exec("DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil { return fmt.Errorf("delete skill grants: %w", err) } // Cascade: remove all user grants for this skill - if _, err := tx.Exec("DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil { return fmt.Errorf("delete skill user grants: %w", err) } // Soft-delete the skill (use 'deleted' status, distinct from 'archived' which means missing deps) - if _, err := tx.Exec("UPDATE skills SET status = 'deleted' WHERE id = $1", id); err != nil { + if _, err := tx.ExecContext(ctx, "UPDATE skills SET status = 'deleted' WHERE id = $1", id); err != nil { return fmt.Errorf("delete skill: %w", err) } @@ -132,12 +158,18 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara return uuid.Nil, fmt.Errorf("get next version: %w", err) } + // Resolve tenant_id: default to MasterTenantID if no tenant in context. + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + id := store.GenNewID() var returnedID uuid.UUID err = tx.QueryRowContext(ctx, - `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW()) - ON CONFLICT (slug) DO UPDATE SET + `INSERT INTO skills (id, name, slug, description, owner_id, tenant_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $11, $12, NOW(), NOW()) + ON CONFLICT (tenant_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, version = EXCLUDED.version, frontmatter = EXCLUDED.frontmatter, file_path = EXCLUDED.file_path, @@ -145,7 +177,7 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara visibility = CASE WHEN skills.status IN ('archived', 'deleted') THEN 'private' ELSE skills.visibility END, status = 'active', updated_at = NOW() RETURNING id`, - id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, version, + id, p.Name, p.Slug, p.Description, p.OwnerID, tenantID, p.Visibility, version, fmJSON, p.FilePath, p.FileSize, p.FileHash, ).Scan(&returnedID) if err != nil { @@ -168,10 +200,18 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara } // GetSkillFilePath returns the filesystem path and version for a skill by UUID. -func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) { - err := s.db.QueryRow( - "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id, - ).Scan(&filePath, &slug, &version) +func (s *PGSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, ok bool) { + q := "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'" + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&filePath, &slug, &version) return filePath, slug, version, err == nil } @@ -209,11 +249,19 @@ func (s *PGSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (i } // ToggleSkill enables or disables a skill by UUID. -func (s *PGSkillStore) ToggleSkill(id uuid.UUID, enabled bool) error { - _, err := s.db.Exec( - `UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2`, - enabled, id, - ) +func (s *PGSkillStore) ToggleSkill(ctx context.Context, id uuid.UUID, enabled bool) error { + q := `UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2` + args := []any{enabled, id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + + _, err := s.db.ExecContext(ctx, q, args...) if err == nil { s.BumpVersion() } diff --git a/internal/store/pg/skills_embedding.go b/internal/store/pg/skills_embedding.go index 41af1fc0..468ad105 100644 --- a/internal/store/pg/skills_embedding.go +++ b/internal/store/pg/skills_embedding.go @@ -22,15 +22,27 @@ func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float3 } vecStr := vectorToString(embedding) - rows, err := s.db.QueryContext(ctx, - `SELECT name, slug, COALESCE(description, ''), version, file_path, - 1 - (embedding <=> $1::vector) AS score - FROM skills - WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL - AND visibility != 'private' - ORDER BY embedding <=> $2::vector - LIMIT $3`, - vecStr, vecStr, limit, + // $1=vec, $2=vec → tenant at $3 (if needed), ORDER vec at $3+len(tcArgs), LIMIT after + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } + tenantCond := "" + if tc != "" { + tenantCond = fmt.Sprintf(" AND (is_system = true OR tenant_id = $%d)", 3) + } + orderN := 3 + len(tcArgs) + limitN := orderN + 1 + q := fmt.Sprintf(`SELECT name, slug, COALESCE(description, ''), version, file_path, + 1 - (embedding <=> $1::vector) AS score + FROM skills + WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL + AND visibility != 'private'%s + ORDER BY embedding <=> $%d::vector + LIMIT $%d`, tenantCond, orderN, limitN) + + rows, err := s.db.QueryContext(ctx, q, + append(append([]any{vecStr}, tcArgs...), vecStr, limit)..., ) if err != nil { return nil, fmt.Errorf("embedding skill search: %w", err) diff --git a/internal/store/pg/skills_grants.go b/internal/store/pg/skills_grants.go index ed0a3c7d..f5273b4e 100644 --- a/internal/store/pg/skills_grants.go +++ b/internal/store/pg/skills_grants.go @@ -2,6 +2,7 @@ package pg import ( "context" + "fmt" "log/slog" "time" @@ -18,10 +19,10 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U return err } _, err := s.db.ExecContext(ctx, - `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = EXCLUDED.pinned_version`, - store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(), + store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(), tenantIDForInsert(ctx), ) if err != nil { return err @@ -43,8 +44,13 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U // RevokeFromAgent revokes a skill grant from an agent. // Auto-demotes visibility from 'internal' back to 'private' when no agent grants remain. func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2", skillID, agentID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2"+tClause, + append([]any{skillID, agentID}, tArgs...)...) if err != nil { return err } @@ -67,8 +73,13 @@ func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uui // ListAgentGrants returns all skill grants for an agent. func (s *PGSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]SkillGrantInfo, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1", agentID) + "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1"+tClause, + append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -95,35 +106,60 @@ func (s *PGSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userI return err } _, err := s.db.ExecContext(ctx, - `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (skill_id, user_id) DO NOTHING`, - store.GenNewID(), skillID, userID, grantedBy, time.Now(), + store.GenNewID(), skillID, userID, grantedBy, time.Now(), tenantIDForInsert(ctx), ) return err } // RevokeFromUser revokes a skill grant from a user. func (s *PGSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2", skillID, userID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2"+tClause, + append([]any{skillID, userID}, tArgs...)...) return err } // ListAccessible returns skills accessible to a given agent+user combination. // Access logic: public → all, private → owner only, internal → check grants. +// System skills (is_system=true) are always visible regardless of tenant. func (s *PGSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) { + // tenant filter: system skills bypass it, tenant-owned skills are filtered + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } + tenantCond := "" + if tc != "" { + // tc is " AND tenant_id = $3"; we need it as an OR condition inside the WHERE + tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 3) + _ = tc // tcArgs carries the value + } + // LEFT JOIN skill_tenant_configs to exclude per-tenant disabled skills. + // stc.enabled = false → skill explicitly disabled for this tenant. + stcJoin := "" + stcFilter := "" + if len(tcArgs) > 0 { + stcJoin = fmt.Sprintf(" LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = $%d", 3) + stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = true)" + } rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1 - LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = $2 - WHERE s.status = 'active' AND ( + LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = $2`+stcJoin+` + WHERE s.status = 'active'`+tenantCond+stcFilter+` AND ( s.is_system = true OR s.visibility = 'public' OR (s.visibility = 'private' AND s.owner_id = $2) OR (s.visibility = 'internal' AND (sag.id IS NOT NULL OR sug.id IS NOT NULL)) ) - ORDER BY s.name`, agentID, userID) + ORDER BY s.name`, append([]any{agentID, userID}, tcArgs...)...) if err != nil { return nil, err } @@ -166,6 +202,14 @@ type SkillWithGrantStatus struct { // ListWithGrantStatus returns all active skills with grant status for a specific agent. func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]SkillWithGrantStatus, error) { + tc, tcArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } + tenantCond := "" + if tc != "" { + tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 2) + } rows, err := s.db.QueryContext(ctx, `SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version, (sag.id IS NOT NULL) AS granted, @@ -173,8 +217,8 @@ func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUI s.is_system FROM skills s LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1 - WHERE s.status = 'active' - ORDER BY s.name`, agentID) + WHERE s.status = 'active'`+tenantCond+` + ORDER BY s.name`, append([]any{agentID}, tcArgs...)...) if err != nil { return nil, err } diff --git a/internal/store/pg/snapshot.go b/internal/store/pg/snapshot.go index 06646cfd..40125058 100644 --- a/internal/store/pg/snapshot.go +++ b/internal/store/pg/snapshot.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -19,7 +21,7 @@ func NewPGSnapshotStore(db *sql.DB) *PGSnapshotStore { return &PGSnapshotStore{db: db} } -const snapshotFieldCount = 21 +const snapshotFieldCount = 22 // maxBatchRows limits each INSERT to stay under PG's 65535 param limit (65535 / 21 ≈ 3120). const maxBatchRows = 3000 @@ -38,6 +40,11 @@ func (s *PGSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store } func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + var vals []string var args []any for i, snap := range snapshots { @@ -53,6 +60,7 @@ func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.Usa snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount, snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS, snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations, + tenantID, ) } @@ -61,9 +69,10 @@ func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.Usa input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens, total_cost, request_count, llm_call_count, tool_call_count, error_count, unique_users, avg_duration_ms, - memory_docs, memory_chunks, kg_entities, kg_relations + memory_docs, memory_chunks, kg_entities, kg_relations, + tenant_id ) VALUES ` + strings.Join(vals, ", ") + ` - ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel) + ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel, tenant_id) DO UPDATE SET input_tokens = EXCLUDED.input_tokens, output_tokens = EXCLUDED.output_tokens, @@ -92,7 +101,7 @@ func (s *PGSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQue bucketExpr = "date_trunc('day', bucket_hour)" } - where, args := buildSnapshotWhere(q) + where, args := buildSnapshotWhere(ctx, q) query := fmt.Sprintf(`SELECT bucket_time, @@ -187,7 +196,7 @@ func (s *PGSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuer extraFilter = " AND provider != '' AND model != ''" } - where, args := buildSnapshotWhere(q) + where, args := buildSnapshotWhere(ctx, q) if where == "" { where = " WHERE 1=1" } @@ -250,11 +259,20 @@ func (s *PGSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, erro } // buildSnapshotWhere builds a dynamic WHERE clause from SnapshotQuery filters. -func buildSnapshotWhere(q store.SnapshotQuery) (string, []any) { +func buildSnapshotWhere(ctx context.Context, q store.SnapshotQuery) (string, []any) { var conds []string var args []any idx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conds = append(conds, fmt.Sprintf("tenant_id = $%d", idx)) + args = append(args, tenantID) + idx++ + } + } + if !q.From.IsZero() { conds = append(conds, fmt.Sprintf("bucket_hour >= $%d", idx)) args = append(args, q.From) diff --git a/internal/store/pg/teams.go b/internal/store/pg/teams.go index d7617b5a..c16e8bf2 100644 --- a/internal/store/pg/teams.go +++ b/internal/store/pg/teams.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "time" "github.com/google/uuid" @@ -48,38 +49,78 @@ func (s *PGTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) erro settings = json.RawMessage(`{}`) } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, team.ID, team.Name, team.LeadAgentID, team.Description, - team.Status, settings, team.CreatedBy, now, now, + team.Status, settings, team.CreatedBy, now, now, tenantID, ) return err } func (s *PGTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID) + return scanTeamRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } row := s.db.QueryRowContext(ctx, - `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID) + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1 AND tenant_id = $2`, teamID, tenantID) return scanTeamRow(row) } func (s *PGTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error { - return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid) } func (s *PGTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for delete") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1 AND tenant_id = $2`, teamID, tid) return err } func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) { + var tenantFilter string + var queryArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + tenantFilter = " WHERE t.tenant_id = $1" + queryArgs = append(queryArgs, tenantID) + } + rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at, COALESCE(a.agent_key, '') AS lead_agent_key, COALESCE(a.display_name, '') AS lead_display_name FROM agent_teams t - LEFT JOIN agents a ON a.id = t.lead_agent_id - ORDER BY t.created_at`) + LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+` + ORDER BY t.created_at`, queryArgs...) if err != nil { return nil, err } @@ -148,10 +189,10 @@ func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) { func (s *PGTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at) - VALUES ($1, $2, $3, $4) + `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (team_id, agent_id) DO UPDATE SET role = EXCLUDED.role`, - teamID, agentID, role, time.Now(), + teamID, agentID, role, time.Now(), tenantIDForInsert(ctx), ) return err } @@ -165,16 +206,27 @@ func (s *PGTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUI } func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at, COALESCE(a.agent_key, '') AS agent_key, COALESCE(a.display_name, '') AS display_name, COALESCE(a.frontmatter, '') AS frontmatter, COALESCE(a.other_config->>'emoji', '') AS emoji FROM agent_team_members m JOIN agents a ON a.id = m.agent_id - WHERE m.team_id = $1 AND a.status = 'active' - ORDER BY m.joined_at`, teamID) + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = $1 AND a.status = 'active'` + args := []any{teamID} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += ` ORDER BY m.joined_at` + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -195,20 +247,31 @@ func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]stor } func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at, COALESCE(a.agent_key, '') AS agent_key, COALESCE(a.display_name, '') AS display_name, COALESCE(a.frontmatter, '') AS frontmatter, COALESCE(a.other_config->>'emoji', '') AS emoji FROM agent_team_members m JOIN agents a ON a.id = m.agent_id + JOIN agent_teams at2 ON at2.id = m.team_id WHERE m.team_id = $1 AND a.status = 'active' AND m.role != $2 AND NOT EXISTS ( - SELECT 1 FROM team_tasks t - WHERE t.owner_agent_id = m.agent_id AND t.team_id = $1 AND t.status = $3 - ) - ORDER BY m.joined_at`, teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress) + SELECT 1 FROM team_tasks tt + WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = $1 AND tt.status = $3 + )` + args := []any{teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += ` ORDER BY m.joined_at` + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -229,16 +292,24 @@ func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([] } func (s *PGTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) { - row := s.db.QueryRowContext(ctx, - `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at + q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at FROM agent_teams t WHERE ( t.lead_agent_id = $1 OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = $1) - ) AND t.status = $2 - ORDER BY (t.lead_agent_id = $1) DESC - LIMIT 1`, agentID, store.TeamStatusActive) + ) AND t.status = $2` + args := []any{agentID, store.TeamStatusActive} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += ` ORDER BY (t.lead_agent_id = $1) DESC LIMIT 1` + + row := s.db.QueryRowContext(ctx, q, args...) d, err := scanTeamRow(row) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -250,13 +321,24 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit if limit <= 0 { limit = 100 } - rows, err := s.db.QueryContext(ctx, - `SELECT DISTINCT s.user_id + q := `SELECT DISTINCT s.user_id FROM sessions s JOIN agent_team_members m ON m.agent_id = s.agent_id - WHERE m.team_id = $1 AND s.user_id != '' - ORDER BY s.user_id - LIMIT $2`, teamID, limit) + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = $1 AND s.user_id != ''` + args := []any{teamID} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += fmt.Sprintf(" ORDER BY s.user_id LIMIT $%d", len(args)+1) + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -273,6 +355,110 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit return users, rows.Err() } +// ============================================================ +// Team user grants +// ============================================================ + +func (s *PGTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`, + store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx), + ) + return err +} + +func (s *PGTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM team_user_grants WHERE team_id = $1 AND user_id = $2`+tClause, + append([]any{teamID, userID}, tArgs...)...) + return err +} + +func (s *PGTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at + FROM team_user_grants WHERE team_id = $1`+tClause+` ORDER BY created_at DESC`, + append([]any{teamID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.TeamUserGrant + for rows.Next() { + var g store.TeamUserGrant + if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &g.CreatedAt); err != nil { + return nil, err + } + result = append(result, g) + } + return result, rows.Err() +} + +func (s *PGTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) { + baseQuery := `SELECT ` + teamSelectCols + ` + FROM agent_teams t + WHERE t.status = $1 + AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = $2)` + args := []any{store.TeamStatusActive, userID} + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + baseQuery += ` AND t.tenant_id = $3` + args = append(args, tenantID) + } + baseQuery += ` ORDER BY t.created_at DESC` + + rows, err := s.db.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var teams []store.TeamData + for rows.Next() { + var d store.TeamData + var desc sql.NullString + if err := rows.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + teams = append(teams, d) + } + return teams, rows.Err() +} + +func (s *PGTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return false, err + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = $1 AND user_id = $2`+tClause+`)`, + append([]any{teamID, userID}, tArgs...)..., + ).Scan(&exists) + return exists, err +} + // ============================================================ // Scan helpers // ============================================================ diff --git a/internal/store/pg/teams_delegation.go b/internal/store/pg/teams_delegation.go deleted file mode 100644 index 8d64aa10..00000000 --- a/internal/store/pg/teams_delegation.go +++ /dev/null @@ -1,172 +0,0 @@ -package pg - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// ============================================================ -// Delegation History -// ============================================================ - -func (s *PGTeamStore) SaveDelegationHistory(ctx context.Context, record *store.DelegationHistoryData) error { - if record.ID == uuid.Nil { - record.ID = store.GenNewID() - } - now := time.Now() - record.CreatedAt = now - - metadata, _ := json.Marshal(record.Metadata) - if len(metadata) == 0 { - metadata = []byte(`{}`) - } - - _, err := s.db.ExecContext(ctx, - `INSERT INTO delegation_history (id, source_agent_id, target_agent_id, team_id, team_task_id, user_id, task, mode, status, result, error, iterations, trace_id, duration_ms, metadata, created_at, completed_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, - record.ID, record.SourceAgentID, record.TargetAgentID, - record.TeamID, record.TeamTaskID, - record.UserID, record.Task, record.Mode, record.Status, - record.Result, record.Error, record.Iterations, - record.TraceID, record.DurationMS, metadata, now, record.CompletedAt, - ) - return err -} - -func (s *PGTeamStore) ListDelegationHistory(ctx context.Context, opts store.DelegationHistoryListOpts) ([]store.DelegationHistoryData, int, error) { - where := "WHERE 1=1" - args := []any{} - argN := 0 - - nextArg := func(v any) string { - argN++ - args = append(args, v) - return fmt.Sprintf("$%d", argN) - } - - if opts.SourceAgentID != nil { - where += " AND d.source_agent_id = " + nextArg(*opts.SourceAgentID) - } - if opts.TargetAgentID != nil { - where += " AND d.target_agent_id = " + nextArg(*opts.TargetAgentID) - } - if opts.TeamID != nil { - where += " AND d.team_id = " + nextArg(*opts.TeamID) - } - if opts.UserID != "" { - where += " AND d.user_id = " + nextArg(opts.UserID) - } - if opts.Status != "" { - where += " AND d.status = " + nextArg(opts.Status) - } - - // Count total - var total int - countSQL := "SELECT COUNT(*) FROM delegation_history d " + where - if err := s.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil { - return nil, 0, err - } - - // Fetch rows - limit := opts.Limit - if limit <= 0 || limit > 200 { - limit = 50 - } - offset := max(opts.Offset, 0) - - query := fmt.Sprintf( - `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, - d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, - d.trace_id, d.duration_ms, d.metadata, d.created_at, d.completed_at, - COALESCE(sa.agent_key, '') AS source_agent_key, - COALESCE(ta.agent_key, '') AS target_agent_key - FROM delegation_history d - LEFT JOIN agents sa ON sa.id = d.source_agent_id - LEFT JOIN agents ta ON ta.id = d.target_agent_id - %s - ORDER BY d.created_at DESC - LIMIT %s OFFSET %s`, - where, nextArg(limit), nextArg(offset)) - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var records []store.DelegationHistoryData - for rows.Next() { - var d store.DelegationHistoryData - var result, errStr sql.NullString - var completedAt sql.NullTime - var metadata json.RawMessage - if err := rows.Scan( - &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, - &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, - &d.TraceID, &d.DurationMS, &metadata, &d.CreatedAt, &completedAt, - &d.SourceAgentKey, &d.TargetAgentKey, - ); err != nil { - return nil, 0, err - } - if result.Valid { - d.Result = &result.String - } - if errStr.Valid { - d.Error = &errStr.String - } - if completedAt.Valid { - d.CompletedAt = &completedAt.Time - } - if len(metadata) > 0 && string(metadata) != "{}" { - _ = json.Unmarshal(metadata, &d.Metadata) - } - records = append(records, d) - } - return records, total, rows.Err() -} - -func (s *PGTeamStore) GetDelegationHistory(ctx context.Context, id uuid.UUID) (*store.DelegationHistoryData, error) { - var d store.DelegationHistoryData - var result, errStr sql.NullString - var completedAt sql.NullTime - var metadata json.RawMessage - - err := s.db.QueryRowContext(ctx, - `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, - d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, - d.trace_id, d.duration_ms, d.metadata, d.created_at, d.completed_at, - COALESCE(sa.agent_key, '') AS source_agent_key, - COALESCE(ta.agent_key, '') AS target_agent_key - FROM delegation_history d - LEFT JOIN agents sa ON sa.id = d.source_agent_id - LEFT JOIN agents ta ON ta.id = d.target_agent_id - WHERE d.id = $1`, id).Scan( - &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, - &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, - &d.TraceID, &d.DurationMS, &metadata, &d.CreatedAt, &completedAt, - &d.SourceAgentKey, &d.TargetAgentKey, - ) - if err != nil { - return nil, err - } - if result.Valid { - d.Result = &result.String - } - if errStr.Valid { - d.Error = &errStr.String - } - if completedAt.Valid { - d.CompletedAt = &completedAt.Time - } - if len(metadata) > 0 && string(metadata) != "{}" { - _ = json.Unmarshal(metadata, &d.Metadata) - } - return &d, nil -} diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go index 8657e459..d879ce0c 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -42,10 +42,20 @@ const maxListTasksRows = 30 // ============================================================ func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) { + args := []any{teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT channel, chat_id FROM team_tasks - WHERE team_id = $1 AND channel IS NOT NULL AND channel != '' - ORDER BY channel, chat_id`, teamID) + WHERE team_id = $1 AND channel IS NOT NULL AND channel != ''`+tenantWhere+` + ORDER BY channel, chat_id`, args...) if err != nil { return nil, err } @@ -53,11 +63,11 @@ func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]s var scopes []store.ScopeEntry for rows.Next() { - var s store.ScopeEntry - if err := rows.Scan(&s.Channel, &s.ChatID); err != nil { + var sc store.ScopeEntry + if err := rows.Scan(&sc.Channel, &sc.ChatID); err != nil { return nil, err } - scopes = append(scopes, s) + scopes = append(scopes, sc) } return scopes, rows.Err() } @@ -117,8 +127,8 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) // INSERT with all fields in one statement. _, err = tx.ExecContext(ctx, `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel, - task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, + task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`, task.ID, task.TeamID, task.Subject, task.Description, task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy), task.Priority, task.Result, @@ -129,7 +139,7 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) sql.NullString{String: task.ChatID, Valid: task.ChatID != ""}, metaJSON, task.LockedAt, task.LockExpiresAt, - now, now, + now, now, tenantIDForInsert(ctx), ) if err != nil { return err @@ -171,8 +181,18 @@ func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates updates["blocked_by"] = pq.Array(v) } updates["updated_at"] = time.Now() - if err := execMapUpdate(ctx, s.db, "team_tasks", taskID, updates); err != nil { - return err + var updateErr error + if store.IsCrossTenant(ctx) { + updateErr = execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + updateErr = execMapUpdateWhereTenant(ctx, s.db, "team_tasks", updates, taskID, tid) + } + if updateErr != nil { + return updateErr } // Re-embed when subject changes. @@ -207,12 +227,26 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s // Scope filter: always bind $4/$5 but only enforce when non-empty. scopeWhere := "AND ($4 = '' OR COALESCE(t.channel,'') = $4) AND ($5 = '' OR COALESCE(t.chat_id,'') = $5)" + // Base args: $1=teamID, $2=userID, $3=limit+1, $4=channel, $5=chatID, $6=offset + // Tenant clause appended as $7 when needed (after offset which stays at $6). + args := []any{teamID, userID, limit + 1, channel, chatID, offset} + + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+` + WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+tenantWhere+` ORDER BY `+orderClause+` - LIMIT $3 OFFSET $6`, teamID, userID, limit+1, channel, chatID, offset) + LIMIT $3 OFFSET $6`, args...) if err != nil { return nil, err } @@ -221,10 +255,20 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s } func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) { + args := []any{taskID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.id = $1`, taskID) + WHERE t.id = $1`+tenantWhere, args...) if err != nil { return nil, err } @@ -243,10 +287,20 @@ func (s *PGTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]sto if len(ids) == 0 { return nil, nil } + args := []any{pq.Array(ids)} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.id = ANY($1)`, pq.Array(ids)) + WHERE t.id = ANY($1)`+tenantWhere, args...) if err != nil { return nil, err } @@ -287,12 +341,23 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s if s.embProvider != nil { ftsLimit = limit * 2 // fetch more for hybrid merge } + // Base args: $1=teamID, $2=tsq, $3=ftsLimit, $4=userID + ftsArgs := []any{teamID, tsq, ftsLimit, userID} + ftsTenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + ftsTenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(ftsArgs)+1) + ftsArgs = append(ftsArgs, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4) + WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4)`+ftsTenantWhere+` ORDER BY ts_rank(t.tsv, to_tsquery('simple', $2)) DESC - LIMIT $3`, teamID, tsq, ftsLimit, userID) + LIMIT $3`, ftsArgs...) if err != nil { return nil, err } @@ -335,9 +400,19 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s } func (s *PGTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error { + args := []any{taskID, teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } res, err := s.db.ExecContext(ctx, - `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`, - taskID, teamID) + `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`+tenantWhere, + args...) if err != nil { return err } @@ -352,11 +427,20 @@ func (s *PGTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, team if len(taskIDs) == 0 { return nil, nil } + args := []any{pq.Array(taskIDs), teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `DELETE FROM team_tasks - WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled') - RETURNING id`, - taskIDs, teamID) + WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled')`+tenantWhere+` + RETURNING id`, args...) if err != nil { return nil, err } diff --git a/internal/store/pg/teams_tasks_activity.go b/internal/store/pg/teams_tasks_activity.go index 0cde2567..775e81ac 100644 --- a/internal/store/pg/teams_tasks_activity.go +++ b/internal/store/pg/teams_tasks_activity.go @@ -21,29 +21,30 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas } comment.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at) - VALUES ($1, $2, $3, $4, $5, $6)`, + `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, comment.ID, comment.TaskID, comment.AgentID, sql.NullString{String: comment.UserID, Valid: comment.UserID != ""}, - comment.Content, comment.CreatedAt, + comment.Content, comment.CreatedAt, tenantIDForInsert(ctx), ) if err != nil { return err } // Increment denormalized comment count. _, _ = s.db.ExecContext(ctx, - `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = $1`, comment.TaskID) + `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = $1 AND tenant_id = $2`, comment.TaskID, tenantIDForInsert(ctx)) return nil } func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at, COALESCE(a.agent_key, '') AS agent_key FROM team_task_comments c LEFT JOIN agents a ON a.id = c.agent_id - WHERE c.task_id = $1 - ORDER BY c.created_at ASC`, taskID) + WHERE c.task_id = $1 AND c.tenant_id = $2 + ORDER BY c.created_at ASC`, taskID, tid) if err != nil { return nil, err } @@ -69,14 +70,15 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([ // ListRecentTaskComments returns the N most recent comments for a task (DESC order). // Used by dispatch to include context without fetching all comments. func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at, COALESCE(a.agent_key, '') AS agent_key FROM team_task_comments c LEFT JOIN agents a ON a.id = c.agent_id - WHERE c.task_id = $1 + WHERE c.task_id = $1 AND c.tenant_id = $3 ORDER BY c.created_at DESC - LIMIT $2`, taskID, limit) + LIMIT $2`, taskID, limit, tid) if err != nil { return nil, err } @@ -116,19 +118,20 @@ func (s *PGTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTask } event.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, + `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, event_type, actor_type, actor_id, data, created_at FROM team_task_events - WHERE task_id = $1 - ORDER BY created_at ASC`, taskID) + WHERE task_id = $1 AND tenant_id = $2 + ORDER BY created_at ASC`, taskID, tid) if err != nil { return nil, err } @@ -151,14 +154,15 @@ func (s *PGTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limi if limit <= 0 || limit > 100 { limit = 50 } + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at FROM team_task_events e JOIN team_tasks t ON t.id = e.task_id - WHERE t.team_id = $1 + WHERE t.team_id = $1 AND t.tenant_id = $4 ORDER BY e.created_at DESC LIMIT $2 OFFSET $3`, - teamID, limit, offset) + teamID, limit, offset, tid) if err != nil { return nil, err } @@ -190,13 +194,13 @@ func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskA att.Metadata = json.RawMessage(`{}`) } res, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (task_id, path) DO NOTHING`, att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path, att.FileSize, att.MimeType, att.CreatedByAgentID, sql.NullString{String: att.CreatedBySenderID, Valid: att.CreatedBySenderID != ""}, - att.Metadata, att.CreatedAt, + att.Metadata, att.CreatedAt, tenantIDForInsert(ctx), ) if err != nil { return err @@ -204,7 +208,7 @@ func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskA // Increment denormalized count only if a row was actually inserted (not conflict). if n, _ := res.RowsAffected(); n > 0 { _, _ = s.db.ExecContext(ctx, - `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = $1`, att.TaskID) + `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = $1 AND tenant_id = $2`, att.TaskID, tenantIDForInsert(ctx)) } return nil } @@ -214,10 +218,11 @@ func (s *PGTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) var agentID *uuid.UUID var senderID sql.NullString var metadata json.RawMessage + tid := tenantIDForInsert(ctx) err := s.db.QueryRowContext(ctx, `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at - FROM team_task_attachments WHERE id = $1`, attachmentID, + FROM team_task_attachments WHERE id = $1 AND tenant_id = $2`, attachmentID, tid, ).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType, &agentID, &senderID, &metadata, &a.CreatedAt) if err != nil { @@ -232,12 +237,13 @@ func (s *PGTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) } func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at FROM team_task_attachments - WHERE task_id = $1 - ORDER BY created_at ASC`, taskID) + WHERE task_id = $1 AND tenant_id = $2 + ORDER BY created_at ASC`, taskID, tid) if err != nil { return nil, err } @@ -264,9 +270,10 @@ func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) } func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, - `DELETE FROM team_task_attachments WHERE task_id = $1 AND path = $2`, - taskID, path, + `DELETE FROM team_task_attachments WHERE task_id = $1 AND path = $2 AND tenant_id = $3`, + taskID, path, tid, ) if err != nil { return err @@ -274,7 +281,7 @@ func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, // Decrement denormalized count only if a row was actually deleted. if n, _ := res.RowsAffected(); n > 0 { _, _ = s.db.ExecContext(ctx, - `UPDATE team_tasks SET attachment_count = GREATEST(attachment_count - 1, 0) WHERE id = $1`, taskID) + `UPDATE team_tasks SET attachment_count = GREATEST(attachment_count - 1, 0) WHERE id = $1 AND tenant_id = $2`, taskID, tid) } return nil } diff --git a/internal/store/pg/teams_tasks_embedding.go b/internal/store/pg/teams_tasks_embedding.go index 38d916a0..ee21a027 100644 --- a/internal/store/pg/teams_tasks_embedding.go +++ b/internal/store/pg/teams_tasks_embedding.go @@ -115,15 +115,17 @@ func (s *PGTeamStore) SearchTasksByEmbedding(ctx context.Context, teamID uuid.UU limit = 5 } vecStr := vectorToString(embedding) + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` WHERE t.team_id = $1 AND t.embedding IS NOT NULL AND ($4 = '' OR t.user_id = $4) + AND t.tenant_id = $5 ORDER BY t.embedding <=> $2::vector LIMIT $3`, - teamID, vecStr, limit, userID) + teamID, vecStr, limit, userID, tid) if err != nil { return nil, err } diff --git a/internal/store/pg/teams_tasks_followup.go b/internal/store/pg/teams_tasks_followup.go index 97a3a9cf..72bf8a6c 100644 --- a/internal/store/pg/teams_tasks_followup.go +++ b/internal/store/pg/teams_tasks_followup.go @@ -16,11 +16,12 @@ import ( func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error { now := time.Now() + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = $1, followup_max = $2, followup_message = $3, followup_channel = $4, followup_chat_id = $5, updated_at = $6 - WHERE id = $7 AND team_id = $8 AND status = $9`, + WHERE id = $7 AND team_id = $8 AND status = $9 AND tenant_id = $10`, followupAt, max, message, channel, chatID, now, - taskID, teamID, store.TeamTaskStatusInProgress, + taskID, teamID, store.TeamTaskStatusInProgress, tid, ) if err != nil { return err @@ -36,10 +37,11 @@ func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.U } func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error { + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = $1 - WHERE id = $2`, - time.Now(), taskID, + WHERE id = $2 AND tenant_id = $3`, + time.Now(), taskID, tid, ) return err } @@ -67,22 +69,24 @@ func (s *PGTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.Team } func (s *PGTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error { + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = $1, updated_at = $2 - WHERE id = $3`, - nextAt, time.Now(), taskID, + WHERE id = $3 AND tenant_id = $4`, + nextAt, time.Now(), taskID, tid, ) return err } func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = NOW() WHERE followup_channel = $1 AND followup_chat_id = $2 - AND followup_at IS NOT NULL AND status = $3`, - channel, chatID, store.TeamTaskStatusInProgress, + AND followup_at IS NOT NULL AND status = $3 AND tenant_id = $4`, + channel, chatID, store.TeamTaskStatusInProgress, tid, ) if err != nil { return 0, err @@ -92,6 +96,7 @@ func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID } func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = $4, followup_max = $5, followup_message = $6, @@ -99,12 +104,13 @@ func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid WHERE team_id = $1 AND status = $7 AND followup_at IS NULL + AND tenant_id = $8 AND ( (COALESCE(channel,'') = $2 AND COALESCE(chat_id,'') = $3) OR followup_channel = $2 OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '') )`, - teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress, + teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress, tid, ) if err != nil { return 0, err @@ -114,6 +120,7 @@ func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid } func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) { + tid := tenantIDForInsert(ctx) var exists bool err := s.db.QueryRowContext(ctx, `SELECT EXISTS( @@ -122,9 +129,10 @@ func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID AND owner_agent_id IS NOT NULL AND owner_agent_id != $2 AND status IN ($3, $4, $5) + AND tenant_id = $6 )`, teamID, excludeAgentID, - store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, + store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid, ).Scan(&exists) return exists, err } diff --git a/internal/store/pg/teams_tasks_lifecycle.go b/internal/store/pg/teams_tasks_lifecycle.go index d474eeee..70885423 100644 --- a/internal/store/pg/teams_tasks_lifecycle.go +++ b/internal/store/pg/teams_tasks_lifecycle.go @@ -14,11 +14,12 @@ import ( func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error { now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3 - WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7`, + WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7 AND tenant_id = $8`, store.TeamTaskStatusInProgress, agentID, now, lockExpires, - taskID, store.TeamTaskStatusPending, teamID, + taskID, store.TeamTaskStatusPending, teamID, tid, ) if err != nil { return err @@ -36,11 +37,12 @@ func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uui func (s *PGTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error { now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3 - WHERE id = $5 AND team_id = $6 AND status = $7`, + WHERE id = $5 AND team_id = $6 AND status = $7 AND tenant_id = $8`, store.TeamTaskStatusInProgress, agentID, now, lockExpires, - taskID, teamID, store.TeamTaskStatusPending, + taskID, teamID, store.TeamTaskStatusPending, tid, ) if err != nil { return err @@ -62,14 +64,15 @@ func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID } defer tx.Rollback() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status = $5 AND team_id = $6`, + WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusCompleted, result, time.Now(), - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -96,14 +99,15 @@ func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6`, + WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusCancelled, reason, now, - taskID, store.TeamTaskStatusCompleted, teamID, + taskID, store.TeamTaskStatusCompleted, teamID, tid, ) if err != nil { return err @@ -130,14 +134,15 @@ func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, er defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status = $5 AND team_id = $6`, + WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusFailed, "FAILED: "+errMsg, now, - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -165,12 +170,13 @@ func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.U defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status IN ($5, $6) AND team_id = $7`, + WHERE id = $4 AND status IN ($5, $6) AND team_id = $7 AND tenant_id = $8`, store.TeamTaskStatusFailed, "FAILED: "+errMsg, now, - taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, + taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, tid, ) if err != nil { return err @@ -191,24 +197,27 @@ func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.U // unblockDependentTasks removes taskID from blocked_by arrays and transitions blocked→pending // when all blockers are resolved. Must be called within a transaction. +// Scoped by tenant_id to prevent cross-tenant unblocking. func unblockDependentTasks(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error { + tid := tenantIDForInsert(ctx) _, err := tx.ExecContext(ctx, `UPDATE team_tasks SET blocked_by = array_remove(blocked_by, $1), status = CASE WHEN status = 'blocked' AND array_length(array_remove(blocked_by, $1), 1) IS NULL THEN 'pending' ELSE status END, updated_at = $2 - WHERE $1 = ANY(blocked_by)`, - taskID, time.Now(), + WHERE $1 = ANY(blocked_by) AND tenant_id = $3`, + taskID, time.Now(), tid, ) return err } func (s *PGTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, updated_at = $2 - WHERE id = $3 AND status = $4 AND team_id = $5`, + WHERE id = $3 AND status = $4 AND team_id = $5 AND tenant_id = $6`, store.TeamTaskStatusInReview, time.Now(), - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -231,14 +240,15 @@ func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $2 - WHERE id = $3 AND status = $4 AND team_id = $5`, + WHERE id = $3 AND status = $4 AND team_id = $5 AND tenant_id = $6`, store.TeamTaskStatusCompleted, now, - taskID, store.TeamTaskStatusInReview, teamID, + taskID, store.TeamTaskStatusInReview, teamID, tid, ) if err != nil { return err @@ -265,14 +275,15 @@ func (s *PGTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status = $5 AND team_id = $6`, + WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusCancelled, reason, now, - taskID, store.TeamTaskStatusInReview, teamID, + taskID, store.TeamTaskStatusInReview, teamID, tid, ) if err != nil { return err diff --git a/internal/store/pg/teams_tasks_progress.go b/internal/store/pg/teams_tasks_progress.go index 56e682ef..df0a97cb 100644 --- a/internal/store/pg/teams_tasks_progress.go +++ b/internal/store/pg/teams_tasks_progress.go @@ -21,11 +21,12 @@ func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uui // Also renews lock_expires_at as a heartbeat. now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4 - WHERE id = $5 AND status = $6 AND team_id = $7`, + WHERE id = $5 AND status = $6 AND team_id = $7 AND tenant_id = $8`, percent, step, lockExpires, now, - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -46,11 +47,12 @@ func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uui func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error { now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET lock_expires_at = $1, updated_at = $2 - WHERE id = $3 AND team_id = $4 AND status = $5`, + WHERE id = $3 AND team_id = $4 AND status = $5 AND tenant_id = $6`, lockExpires, now, - taskID, teamID, store.TeamTaskStatusInProgress, + taskID, teamID, store.TeamTaskStatusInProgress, tid, ) if err != nil { return err @@ -84,7 +86,7 @@ func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.Recover AND COALESCE((tm.settings->>'version')::int, 0) >= 2 AND t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $2 - RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, ) if err != nil { @@ -106,7 +108,7 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover WHERE t.team_id = tm.id AND tm.status = 'active' AND COALESCE((tm.settings->>'version')::int, 0) >= 2 AND t.status = $3 - RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, ) if err != nil { @@ -120,17 +122,19 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover // Used by DispatchUnblockedTasks after task completion. func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) { now := time.Now() + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` WHERE t.team_id = $1 + AND t.tenant_id = $6 AND ( t.status = $2 OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4) ) ORDER BY t.priority DESC, t.created_at LIMIT $5`, - teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows) + teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows, tid) if err != nil { return nil, err } @@ -148,7 +152,7 @@ func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time WHERE t.team_id = tm.id AND tm.status = 'active' AND COALESCE((tm.settings->>'version')::int, 0) >= 2 AND t.status = $3 AND t.updated_at < $4 - RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan, ) if err != nil { @@ -166,7 +170,7 @@ func scanRecoveredTaskInfoRows(rows interface { var out []store.RecoveredTaskInfo for rows.Next() { var info store.RecoveredTaskInfo - if err := rows.Scan(&info.ID, &info.TeamID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil { + if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil { return nil, err } out = append(out, info) @@ -179,12 +183,13 @@ func scanRecoveredTaskInfoRows(rows interface { func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error { now := time.Now() + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL, progress_percent = NULL, progress_step = NULL, updated_at = $2 - WHERE id = $3 AND team_id = $4 AND status IN ($5, $6)`, + WHERE id = $3 AND team_id = $4 AND status IN ($5, $6) AND tenant_id = $7`, store.TeamTaskStatusPending, now, - taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, + taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, tid, ) if err != nil { return err diff --git a/internal/store/pg/tenant_configs.go b/internal/store/pg/tenant_configs.go new file mode 100644 index 00000000..7ae29f34 --- /dev/null +++ b/internal/store/pg/tenant_configs.go @@ -0,0 +1,126 @@ +package pg + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +// PGBuiltinToolTenantConfigStore implements store.BuiltinToolTenantConfigStore. +type PGBuiltinToolTenantConfigStore struct { + db *sql.DB +} + +func NewPGBuiltinToolTenantConfigStore(db *sql.DB) *PGBuiltinToolTenantConfigStore { + return &PGBuiltinToolTenantConfigStore{db: db} +} + +func (s *PGBuiltinToolTenantConfigStore) ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tool_name FROM builtin_tool_tenant_configs WHERE tenant_id = $1 AND enabled = false`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +func (s *PGBuiltinToolTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tool_name, enabled FROM builtin_tool_tenant_configs WHERE tenant_id = $1`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make(map[string]bool) + for rows.Next() { + var name string + var enabled bool + if err := rows.Scan(&name, &enabled); err != nil { + return nil, err + } + result[name] = enabled + } + return result, rows.Err() +} + +func (s *PGBuiltinToolTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO builtin_tool_tenant_configs (tool_name, tenant_id, enabled, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tool_name, tenant_id) DO UPDATE SET enabled = $3, updated_at = $4`, + toolName, tenantID, enabled, time.Now(), + ) + return err +} + +func (s *PGBuiltinToolTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM builtin_tool_tenant_configs WHERE tool_name = $1 AND tenant_id = $2`, + toolName, tenantID, + ) + return err +} + +// PGSkillTenantConfigStore implements store.SkillTenantConfigStore. +type PGSkillTenantConfigStore struct { + db *sql.DB +} + +func NewPGSkillTenantConfigStore(db *sql.DB) *PGSkillTenantConfigStore { + return &PGSkillTenantConfigStore{db: db} +} + +func (s *PGSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT skill_id FROM skill_tenant_configs WHERE tenant_id = $1 AND enabled = false`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *PGSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (skill_id, tenant_id) DO UPDATE SET enabled = $3, updated_at = $4`, + skillID, tenantID, enabled, time.Now(), + ) + return err +} + +func (s *PGSkillTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM skill_tenant_configs WHERE skill_id = $1 AND tenant_id = $2`, + skillID, tenantID, + ) + return err +} diff --git a/internal/store/pg/tenant_store.go b/internal/store/pg/tenant_store.go new file mode 100644 index 00000000..7aafb994 --- /dev/null +++ b/internal/store/pg/tenant_store.go @@ -0,0 +1,229 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGTenantStore implements store.TenantStore backed by Postgres. +type PGTenantStore struct { + db *sql.DB +} + +// NewPGTenantStore creates a new PostgreSQL-backed tenant store. +func NewPGTenantStore(db *sql.DB) *PGTenantStore { + return &PGTenantStore{db: db} +} + +// ============================================================ +// Tenant CRUD +// ============================================================ + +func (s *PGTenantStore) CreateTenant(ctx context.Context, tenant *store.TenantData) error { + if tenant.ID == uuid.Nil { + tenant.ID = store.GenNewID() + } + now := time.Now() + tenant.CreatedAt = now + tenant.UpdatedAt = now + + settings := tenant.Settings + if len(settings) == 0 { + settings = json.RawMessage(`{}`) + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + tenant.ID, tenant.Name, tenant.Slug, tenant.Status, settings, now, now, + ) + return err +} + +func (s *PGTenantStore) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants WHERE id = $1`, id) + return scanTenantRow(row) +} + +func (s *PGTenantStore) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants WHERE slug = $1`, slug) + return scanTenantRow(row) +} + +func (s *PGTenantStore) ListTenants(ctx context.Context) ([]store.TenantData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var tenants []store.TenantData + for rows.Next() { + d, err := scanTenantRows(rows) + if err != nil { + return nil, err + } + tenants = append(tenants, *d) + } + return tenants, rows.Err() +} + +func (s *PGTenantStore) UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error { + return execMapUpdate(ctx, s.db, "tenants", id, updates) +} + +// ============================================================ +// Tenant-user membership +// ============================================================ + +func (s *PGTenantStore) AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO tenant_users (id, tenant_id, user_id, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role, updated_at = EXCLUDED.updated_at`, + store.GenNewID(), tenantID, userID, role, now, now, + ) + return err +} + +func (s *PGTenantStore) GetTenantUser(ctx context.Context, id uuid.UUID) (*store.TenantUserData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE id = $1`, id) + var d store.TenantUserData + if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + return &d, nil +} + +func (s *PGTenantStore) CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*store.TenantUserData, error) { + now := time.Now() + var dn *string + if displayName != "" { + dn = &displayName + } + row := s.db.QueryRowContext(ctx, + `INSERT INTO tenant_users (id, tenant_id, user_id, display_name, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET + display_name = COALESCE(EXCLUDED.display_name, tenant_users.display_name), + updated_at = EXCLUDED.updated_at + RETURNING id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at`, + store.GenNewID(), tenantID, userID, dn, role, now, now, + ) + var d store.TenantUserData + if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + return &d, nil +} + +func (s *PGTenantStore) RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`, + tenantID, userID, + ) + return err +} + +func (s *PGTenantStore) GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) { + var role string + err := s.db.QueryRowContext(ctx, + `SELECT role FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`, + tenantID, userID, + ).Scan(&role) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return role, err +} + +func (s *PGTenantStore) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]store.TenantUserData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE tenant_id = $1 ORDER BY created_at`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTenantUserRows(rows) +} + +func (s *PGTenantStore) ListUserTenants(ctx context.Context, userID string) ([]store.TenantUserData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE user_id = $1 ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTenantUserRows(rows) +} + +func (s *PGTenantStore) ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) { + var tenantID uuid.UUID + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id FROM tenant_users WHERE user_id = $1 ORDER BY created_at LIMIT 1`, + userID, + ).Scan(&tenantID) + if errors.Is(err, sql.ErrNoRows) { + return store.MasterTenantID, nil + } + if err != nil { + return uuid.Nil, err + } + return tenantID, nil +} + +// ============================================================ +// Scan helpers +// ============================================================ + +func scanTenantRow(row *sql.Row) (*store.TenantData, error) { + var d store.TenantData + err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +type tenantRowScanner interface { + Scan(dest ...any) error +} + +func scanTenantRows(row tenantRowScanner) (*store.TenantData, error) { + var d store.TenantData + err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +func scanTenantUserRows(rows *sql.Rows) ([]store.TenantUserData, error) { + var result []store.TenantUserData + for rows.Next() { + var d store.TenantUserData + if err := rows.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + result = append(result, d) + } + return result, rows.Err() +} diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index c34af61f..bb99c2e7 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -26,24 +26,35 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData if trace.ID == uuid.Nil { trace.ID = store.GenNewID() } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, `INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count, - status, error, metadata, tags, team_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`, + status, error, metadata, tags, team_id, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)`, trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey), nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime), nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel), nilStr(trace.InputPreview), nilStr(trace.OutputPreview), trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount, - trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, + trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, tenantID, ) return err } func (s *PGTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error { - return execMapUpdate(ctx, s.db, "traces", traceID, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "traces", traceID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "traces", updates, traceID, tid) } func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) { @@ -55,13 +66,22 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor var metadata *[]byte var tags []byte - err := s.db.QueryRowContext(ctx, - `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, + query := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, status, error, metadata, tags, team_id, created_at - FROM traces WHERE id = $1`, traceID, - ).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, + FROM traces WHERE id = $1` + qArgs := []any{traceID} + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } + query += ` AND tenant_id = $2` + qArgs = append(qArgs, tenantID) + } + + err := s.db.QueryRowContext(ctx, query, qArgs...).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, &durationMS, &name, &channel, &inputPreview, &outputPreview, &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, &d.Status, &errStr, &metadata, &tags, &teamID, &d.CreatedAt) @@ -91,11 +111,21 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor return &d, nil } -func buildTraceWhere(opts store.TraceListOpts) (string, []any) { +func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []any) { var conditions []string var args []any argIdx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return " WHERE 1=0", nil // fail-closed: no tenant = no results + } + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + if opts.AgentID != nil { conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) args = append(args, *opts.AgentID) @@ -130,14 +160,14 @@ func buildTraceWhere(opts store.TraceListOpts) (string, []any) { } func (s *PGTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) { - where, args := buildTraceWhere(opts) + where, args := buildTraceWhere(ctx, opts) var count int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces"+where, args...).Scan(&count) return count, err } func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) { - where, args := buildTraceWhere(opts) + where, args := buildTraceWhere(ctx, opts) q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, @@ -203,9 +233,20 @@ func (s *PGTracingStore) ListChildTraces(ctx context.Context, parentTraceID uuid duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, status, error, metadata, tags, team_id, created_at - FROM traces WHERE parent_trace_id = $1 ORDER BY created_at` + FROM traces WHERE parent_trace_id = $1` + qArgs := []any{parentTraceID} - rows, err := s.db.QueryContext(ctx, q, parentTraceID) + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = $2" + qArgs = append(qArgs, tid) + } + q += " ORDER BY created_at" + + rows, err := s.db.QueryContext(ctx, q, qArgs...) if err != nil { return nil, err } @@ -256,18 +297,22 @@ func (s *PGTracingStore) CreateSpan(ctx context.Context, span *store.SpanData) e if span.ID == uuid.Nil { span.ID = store.GenNewID() } + tenantID := span.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name, start_time, end_time, duration_ms, status, error, level, model, provider, input_tokens, output_tokens, finish_reason, model_params, tool_name, tool_call_id, input_preview, output_preview, - metadata, team_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`, + metadata, team_id, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)`, span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name), span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level, nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason), jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview), - jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, + jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID, ) return err } @@ -349,7 +394,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa } // Build multi-row INSERT - const cols = 25 + const cols = 26 valueGroups := make([]string, len(spans)) args := make([]any, 0, len(spans)*cols) @@ -358,6 +403,10 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa span.ID = store.GenNewID() spans[i].ID = span.ID } + tenantID := span.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } base := i * cols placeholders := make([]string, cols) for j := range cols { @@ -370,7 +419,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level, nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason), jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview), - jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, + jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID, ) } @@ -378,7 +427,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa start_time, end_time, duration_ms, status, error, level, model, provider, input_tokens, output_tokens, finish_reason, model_params, tool_name, tool_call_id, input_preview, output_preview, - metadata, team_id, created_at) + metadata, team_id, created_at, tenant_id) VALUES ` + strings.Join(valueGroups, ", ") _, err := s.db.ExecContext(ctx, q, args...) @@ -423,12 +472,21 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) { start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) + + q := `SELECT COALESCE(SUM(total_cost), 0) FROM traces + WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL` + qArgs := []any{agentID, start, end} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND tenant_id = $4" + qArgs = append(qArgs, tid) + } + } + var cost float64 - err := s.db.QueryRowContext(ctx, - `SELECT COALESCE(SUM(total_cost), 0) FROM traces - WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`, - agentID, start, end, - ).Scan(&cost) + err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&cost) return cost, err } @@ -440,6 +498,15 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm // Only root traces (not delegations) conditions = append(conditions, "parent_trace_id IS NULL") + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + } + if opts.AgentID != nil { conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) args = append(args, *opts.AgentID) diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index e597d017..eae7047c 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -62,6 +62,7 @@ var ValidProviderTypes = map[string]bool{ // LLMProviderData represents an LLM provider configuration. type LLMProviderData struct { BaseModel + TenantID uuid.UUID `json:"tenant_id,omitempty"` Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` ProviderType string `json:"provider_type"` diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 51074d49..80ac9797 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -1,6 +1,7 @@ package store import ( + "context" "time" "github.com/google/uuid" @@ -52,11 +53,12 @@ type SessionInfo struct { // SessionListOpts holds pagination options for ListPaged. type SessionListOpts struct { - AgentID string - Channel string // optional: filter by channel prefix ("ws", "telegram", etc.) - UserID string // optional: filter by user_id - Limit int - Offset int + AgentID string + Channel string // optional: filter by channel prefix ("ws", "telegram", etc.) + UserID string // optional: filter by user_id + TenantID uuid.UUID // optional: filter by tenant (uuid.Nil = no filter) + Limit int + Offset int } // SessionListResult is the paginated result of ListPaged. @@ -86,34 +88,36 @@ type SessionListRichResult struct { // SessionStore manages conversation sessions. type SessionStore interface { - GetOrCreate(key string) *SessionData - AddMessage(key string, msg providers.Message) - GetHistory(key string) []providers.Message - GetSummary(key string) string - SetSummary(key, summary string) - GetLabel(key string) string - SetLabel(key, label string) - SetAgentInfo(key string, agentUUID uuid.UUID, userID string) - UpdateMetadata(key, model, provider, channel string) - AccumulateTokens(key string, input, output int64) - IncrementCompaction(key string) - GetCompactionCount(key string) int - GetMemoryFlushCompactionCount(key string) int - SetMemoryFlushDone(key string) - GetSessionMetadata(key string) map[string]string - SetSessionMetadata(key string, metadata map[string]string) - SetSpawnInfo(key, spawnedBy string, depth int) - SetContextWindow(key string, cw int) - GetContextWindow(key string) int - SetLastPromptTokens(key string, tokens, msgCount int) - GetLastPromptTokens(key string) (tokens, msgCount int) - TruncateHistory(key string, keepLast int) - SetHistory(key string, msgs []providers.Message) - Reset(key string) - Delete(key string) error - List(agentID string) []SessionInfo - ListPaged(opts SessionListOpts) SessionListResult - ListPagedRich(opts SessionListOpts) SessionListRichResult - Save(key string) error - LastUsedChannel(agentID string) (channel, chatID string) + GetOrCreate(ctx context.Context, key string) *SessionData + // Get returns the session if it exists (cache or DB), nil otherwise. Never creates. + Get(ctx context.Context, key string) *SessionData + AddMessage(ctx context.Context, key string, msg providers.Message) + GetHistory(ctx context.Context, key string) []providers.Message + GetSummary(ctx context.Context, key string) string + SetSummary(ctx context.Context, key, summary string) + GetLabel(ctx context.Context, key string) string + SetLabel(ctx context.Context, key, label string) + SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) + UpdateMetadata(ctx context.Context, key, model, provider, channel string) + AccumulateTokens(ctx context.Context, key string, input, output int64) + IncrementCompaction(ctx context.Context, key string) + GetCompactionCount(ctx context.Context, key string) int + GetMemoryFlushCompactionCount(ctx context.Context, key string) int + SetMemoryFlushDone(ctx context.Context, key string) + GetSessionMetadata(ctx context.Context, key string) map[string]string + SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) + SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) + SetContextWindow(ctx context.Context, key string, cw int) + GetContextWindow(ctx context.Context, key string) int + SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) + GetLastPromptTokens(ctx context.Context, key string) (tokens, msgCount int) + TruncateHistory(ctx context.Context, key string, keepLast int) + SetHistory(ctx context.Context, key string, msgs []providers.Message) + Reset(ctx context.Context, key string) + Delete(ctx context.Context, key string) error + List(ctx context.Context, agentID string) []SessionInfo + ListPaged(ctx context.Context, opts SessionListOpts) SessionListResult + ListPagedRich(ctx context.Context, opts SessionListOpts) SessionListRichResult + Save(ctx context.Context, key string) error + LastUsedChannel(ctx context.Context, agentID string) (channel, chatID string) } diff --git a/internal/store/skill_store.go b/internal/store/skill_store.go index c637150d..93b0226a 100644 --- a/internal/store/skill_store.go +++ b/internal/store/skill_store.go @@ -37,12 +37,12 @@ type SkillSearchResult struct { // SkillStore manages skill discovery and loading. // Backed by Postgres (PGSkillStore) or filesystem (FileSkillStore). type SkillStore interface { - ListSkills() []SkillInfo - LoadSkill(name string) (string, bool) - LoadForContext(allowList []string) string - BuildSummary(allowList []string) string - GetSkill(name string) (*SkillInfo, bool) - FilterSkills(allowList []string) []SkillInfo + ListSkills(ctx context.Context) []SkillInfo + LoadSkill(ctx context.Context, name string) (string, bool) + LoadForContext(ctx context.Context, allowList []string) string + BuildSummary(ctx context.Context, allowList []string) string + GetSkill(ctx context.Context, name string) (*SkillInfo, bool) + FilterSkills(ctx context.Context, allowList []string) []SkillInfo Version() int64 BumpVersion() Dirs() []string diff --git a/internal/store/stores.go b/internal/store/stores.go index 58c0909a..93de55c7 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -14,7 +14,6 @@ type Stores struct { Providers ProviderStore Tracing TracingStore MCP MCPServerStore - CustomTools CustomToolStore ChannelInstances ChannelInstanceStore ConfigSecrets ConfigSecretsStore AgentLinks AgentLinkStore @@ -28,5 +27,8 @@ type Stores struct { SecureCLI SecureCLIStore APIKeys APIKeyStore Heartbeats HeartbeatStore - ConfigPermissions ConfigPermissionStore + ConfigPermissions ConfigPermissionStore + Tenants TenantStore + BuiltinToolTenantCfgs BuiltinToolTenantConfigStore + SkillTenantCfgs SkillTenantConfigStore } diff --git a/internal/store/team_store.go b/internal/store/team_store.go index 18494ab3..60195ce0 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -13,6 +13,7 @@ import ( type RecoveredTaskInfo struct { ID uuid.UUID TeamID uuid.UUID + TenantID uuid.UUID TaskNumber int Subject string Channel string // task's origin channel for notification routing @@ -171,41 +172,17 @@ type TeamTaskAttachmentData struct { CreatedBySenderID string `json:"created_by_sender_id,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` + DownloadURL string `json:"download_url,omitempty"` // signed URL, populated at delivery time } -// DelegationHistoryData represents a persisted delegation record. -type DelegationHistoryData struct { - BaseModel - SourceAgentID uuid.UUID `json:"source_agent_id"` - TargetAgentID uuid.UUID `json:"target_agent_id"` - TeamID *uuid.UUID `json:"team_id,omitempty"` - TeamTaskID *uuid.UUID `json:"team_task_id,omitempty"` - UserID string `json:"user_id,omitempty"` - Task string `json:"task"` - Mode string `json:"mode"` - Status string `json:"status"` - Result *string `json:"result,omitempty"` - Error *string `json:"error,omitempty"` - Iterations int `json:"iterations"` - TraceID *uuid.UUID `json:"trace_id,omitempty"` - DurationMS int `json:"duration_ms"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - - // Joined fields - SourceAgentKey string `json:"source_agent_key,omitempty"` - TargetAgentKey string `json:"target_agent_key,omitempty"` -} - -// DelegationHistoryListOpts configures delegation history queries. -type DelegationHistoryListOpts struct { - SourceAgentID *uuid.UUID - TargetAgentID *uuid.UUID - TeamID *uuid.UUID - UserID string - Status string // "completed", "failed", "" = all - Limit int - Offset int +// TeamUserGrant represents a user's access grant to a team. +type TeamUserGrant struct { + ID uuid.UUID `json:"id"` + TeamID uuid.UUID `json:"team_id"` + UserID string `json:"user_id"` + Role string `json:"role"` + GrantedBy string `json:"granted_by,omitempty"` + CreatedAt time.Time `json:"created_at"` } // ScopeEntry represents a unique channel+chatID scope across tasks. @@ -349,8 +326,10 @@ type TeamStore interface { // ResetTaskStatus resets a stale or failed task back to pending for retry. ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error - // Delegation history - SaveDelegationHistory(ctx context.Context, record *DelegationHistoryData) error - ListDelegationHistory(ctx context.Context, opts DelegationHistoryListOpts) ([]DelegationHistoryData, int, error) - GetDelegationHistory(ctx context.Context, id uuid.UUID) (*DelegationHistoryData, error) + // Team user grants + GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error + RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error + ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]TeamUserGrant, error) + ListUserTeams(ctx context.Context, userID string) ([]TeamData, error) + HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) } diff --git a/internal/store/tenant_config_store.go b/internal/store/tenant_config_store.go new file mode 100644 index 00000000..0a78180a --- /dev/null +++ b/internal/store/tenant_config_store.go @@ -0,0 +1,43 @@ +package store + +import ( + "context" + + "github.com/google/uuid" +) + +// BuiltinToolTenantConfig represents a per-tenant override for a builtin tool. +type BuiltinToolTenantConfig struct { + ToolName string `json:"tool_name"` + TenantID uuid.UUID `json:"tenant_id"` + Enabled *bool `json:"enabled,omitempty"` // nil = use default, false = disabled, true = enabled +} + +// BuiltinToolTenantConfigStore manages per-tenant builtin tool overrides. +type BuiltinToolTenantConfigStore interface { + // ListDisabled returns tool names disabled for a tenant. + ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) + // ListAll returns all tenant overrides (tool_name → enabled) for a tenant. + ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) + // Set creates or updates a tenant tool config. + Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error + // Delete removes a tenant tool config (reverts to default). + Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error +} + +// SkillTenantConfig represents a per-tenant override for a skill. +type SkillTenantConfig struct { + SkillID uuid.UUID `json:"skill_id"` + TenantID uuid.UUID `json:"tenant_id"` + Enabled bool `json:"enabled"` +} + +// SkillTenantConfigStore manages per-tenant skill visibility. +type SkillTenantConfigStore interface { + // ListDisabledSkillIDs returns skill IDs disabled for a tenant. + ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) + // Set creates or updates a tenant skill config. + Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error + // Delete removes a tenant skill config (reverts to default). + Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error +} diff --git a/internal/store/tenant_store.go b/internal/store/tenant_store.go new file mode 100644 index 00000000..a4761fb3 --- /dev/null +++ b/internal/store/tenant_store.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// MasterTenantID is the fixed UUID v7 for the default/master tenant. +// All existing data defaults to this tenant during migration. +var MasterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + +// Tenant status constants. +const ( + TenantStatusActive = "active" + TenantStatusSuspended = "suspended" + TenantStatusArchived = "archived" +) + +// Tenant role constants (hierarchy: owner > admin > operator > member > viewer). +const ( + TenantRoleOwner = "owner" + TenantRoleAdmin = "admin" + TenantRoleOperator = "operator" + TenantRoleMember = "member" + TenantRoleViewer = "viewer" +) + +// TenantData represents a tenant in the database. +type TenantData struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Status string `json:"status"` + Settings json.RawMessage `json:"settings,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TenantUserData represents a user's membership in a tenant. +type TenantUserData struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + UserID string `json:"user_id"` + DisplayName *string `json:"display_name,omitempty"` + Role string `json:"role"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TenantStore manages tenants and tenant-user membership. +type TenantStore interface { + // Tenant CRUD + CreateTenant(ctx context.Context, tenant *TenantData) error + GetTenant(ctx context.Context, id uuid.UUID) (*TenantData, error) + GetTenantBySlug(ctx context.Context, slug string) (*TenantData, error) + ListTenants(ctx context.Context) ([]TenantData, error) + UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error + + // Tenant-user membership + AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error + RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error + GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) + ListUsers(ctx context.Context, tenantID uuid.UUID) ([]TenantUserData, error) + ListUserTenants(ctx context.Context, userID string) ([]TenantUserData, error) + + // ResolveUserTenant returns the tenant_id for a user. + // If user belongs to multiple tenants, returns the first (by created_at). + // If no membership, returns MasterTenantID (backward compat). + ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) + + // GetTenantUser returns a single tenant_user by primary key. + GetTenantUser(ctx context.Context, id uuid.UUID) (*TenantUserData, error) + + // CreateTenantUserReturning creates a tenant_user and returns the row. + // On conflict (tenant_id, user_id), updates role/display_name and returns existing row. + CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*TenantUserData, error) +} diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 55a707e9..1f97a80a 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -93,6 +93,7 @@ type SpanData struct { OutputPreview string `json:"output_preview,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` TeamID *uuid.UUID `json:"team_id,omitempty"` + TenantID uuid.UUID `json:"tenant_id"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/tasks/task_ticker.go b/internal/tasks/task_ticker.go index e2d7fa88..97ec1f16 100644 --- a/internal/tasks/task_ticker.go +++ b/internal/tasks/task_ticker.go @@ -140,9 +140,10 @@ func (t *TaskTicker) recoverAll(forceRecover bool) { // ============================================================ type taskScope struct { - TeamID uuid.UUID - Channel string // from task's origin channel - ChatID string + TeamID uuid.UUID + TenantID uuid.UUID + Channel string // from task's origin channel + ChatID string } // notifyLeaders sends a batched system message per (teamID, channel, chatID) scope to the leader. @@ -154,7 +155,7 @@ func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredT // Group by (team_id, channel, chat_id) → one message per scope. byScope := map[taskScope][]store.RecoveredTaskInfo{} for _, task := range tasks { - key := taskScope{TeamID: task.TeamID, Channel: task.Channel, ChatID: task.ChatID} + key := taskScope{TeamID: task.TeamID, TenantID: task.TenantID, Channel: task.Channel, ChatID: task.ChatID} byScope[key] = append(byScope[key], task) } @@ -205,6 +206,7 @@ func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredT ChatID: chatID, AgentID: lead.AgentKey, UserID: team.CreatedBy, + TenantID: scope.TenantID, Content: content, }) { slog.Warn("task_ticker: inbound buffer full, notification dropped", @@ -225,15 +227,12 @@ func (t *TaskTicker) broadcastStaleEvents(ctx context.Context, tasks []store.Rec continue } seen[task.TeamID] = true - t.msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskStale, - Payload: protocol.TeamTaskEventPayload{ - TeamID: task.TeamID.String(), - Status: store.TeamTaskStatusStale, - Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), - ActorType: "system", - ActorID: "task_ticker", - }, + bus.BroadcastForTenant(t.msgBus, protocol.EventTeamTaskStale, task.TenantID, protocol.TeamTaskEventPayload{ + TeamID: task.TeamID.String(), + Status: store.TeamTaskStatusStale, + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ActorType: "system", + ActorID: "task_ticker", }) } } diff --git a/internal/tools/announce_queue.go b/internal/tools/announce_queue.go index 1166c892..6b7cce6f 100644 --- a/internal/tools/announce_queue.go +++ b/internal/tools/announce_queue.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" ) @@ -28,6 +30,7 @@ type AnnounceMetadata struct { OriginLocalKey string // composite key with topic/thread suffix for routing OriginUserID string OriginSessionKey string // exact parent session key (WS uses non-standard format) + OriginTenantID uuid.UUID // parent tenant for announce routing ParentAgent string OriginTraceID string // parent trace UUID for announce linking OriginRootSpanID string // parent agent's root span UUID diff --git a/internal/tools/boundary_test.go b/internal/tools/boundary_test.go index d60476b2..9001e50c 100644 --- a/internal/tools/boundary_test.go +++ b/internal/tools/boundary_test.go @@ -259,6 +259,66 @@ func TestCheckDeniedPath(t *testing.T) { } } +func TestResolvePathWithAllowed_TenantScoping(t *testing.T) { + // Simulate: tenant workspace is a subdirectory of global workspace. + // Paths outside tenant workspace but inside global should be BLOCKED. + globalWs := t.TempDir() + tenantWs := filepath.Join(globalWs, "tenants", "acme") + otherTenantWs := filepath.Join(globalWs, "tenants", "evil") + if err := os.MkdirAll(tenantWs, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(otherTenantWs, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(otherTenantWs, "secret.txt"), []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tenantWs, "ok.txt"), []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + + // Using tenant workspace as base: should allow files inside tenant workspace + _, err := resolvePathWithAllowed("ok.txt", tenantWs, true, nil) + if err != nil { + t.Fatalf("expected success for file in tenant workspace, got: %v", err) + } + + // Using tenant workspace as base: should BLOCK files in other tenant's workspace + _, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), tenantWs, true, nil) + if err == nil { + t.Fatal("expected error for path in another tenant's workspace, got nil") + } + + // Using GLOBAL workspace as base (the bug): would wrongly allow cross-tenant access + _, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), globalWs, true, nil) + if err != nil { + t.Fatal("global workspace allows all children (demonstrates why tenant scoping matters)") + } +} + +func TestResolvePathWithAllowed_TeamWorkspaceAccess(t *testing.T) { + // Agent workspace and team workspace are separate directories. + // Team workspace should be accessible via allowed prefixes. + agentWs := t.TempDir() + teamWs := t.TempDir() + if err := os.WriteFile(filepath.Join(teamWs, "shared.txt"), []byte("shared"), 0644); err != nil { + t.Fatal(err) + } + + // Without team workspace in allowed: should BLOCK + _, err := resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, nil) + if err == nil { + t.Fatal("expected error without team workspace in allowed prefixes, got nil") + } + + // With team workspace in allowed: should ALLOW + _, err = resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, []string{teamWs}) + if err != nil { + t.Fatalf("expected success with team workspace in allowed prefixes, got: %v", err) + } +} + func TestIsPathInside(t *testing.T) { tests := []struct { child, parent string diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index 21abf50e..3c37c0a2 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -45,6 +45,12 @@ func (s *stubAgentStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _ func (s *stubAgentStore) Create(_ context.Context, _ *store.AgentData) error { return nil } func (s *stubAgentStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *stubAgentStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { + return nil, nil +} +func (s *stubAgentStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) { + return nil, nil +} func (s *stubAgentStore) GetDefault(_ context.Context) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } func (s *stubAgentStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index e38240c6..a6881ec7 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -174,10 +174,9 @@ func RestrictFromCtx(ctx context.Context) (bool, bool) { } func effectiveRestrict(ctx context.Context, toolDefault bool) bool { - if v, ok := RestrictFromCtx(ctx); ok { - return v - } - return toolDefault + // Multi-tenant security: always restrict agents to their workspace. + // Agents must not access files outside their tenant-scoped workspace. + return true } // --- Parent agent model (for subagent inheritance) --- @@ -392,11 +391,14 @@ func PendingTeamDispatchFromCtx(ctx context.Context) *PendingTeamDispatch { func InjectTeamDispatch(ctx context.Context, postTurn PostTurnProcessor) (context.Context, func()) { ptd := NewPendingTeamDispatch() ctx = WithPendingTeamDispatch(ctx, ptd) + // Detach from caller's cancel/deadline but keep values (tenant_id, user_id, etc.) + // so post-turn dispatch isn't aborted when the HTTP request or WS handler returns. + detached := context.WithoutCancel(ctx) drain := func() { ptd.ReleaseTeamLock() if postTurn != nil { for teamID, taskIDs := range ptd.Drain() { - if err := postTurn.ProcessPendingTasks(context.Background(), teamID, taskIDs); err != nil { + if err := postTurn.ProcessPendingTasks(detached, teamID, taskIDs); err != nil { slog.Warn("post_turn: dispatch failed", "team_id", teamID, "error", err) } } diff --git a/internal/tools/cron.go b/internal/tools/cron.go index 20efcf87..7eaab3ad 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -156,17 +156,17 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result { case "status": return t.handleStatus() case "list": - return t.handleList(args, agentID, userID) + return t.handleList(ctx, args, agentID, userID) case "add": return t.handleAdd(ctx, args, agentID, userID) case "update": - return t.handleUpdate(args, agentID, userID) + return t.handleUpdate(ctx, args, agentID, userID) case "remove": - return t.handleRemove(args, agentID, userID) + return t.handleRemove(ctx, args, agentID, userID) case "run": - return t.handleRun(args, agentID, userID) + return t.handleRun(ctx, args, agentID, userID) case "runs": - return t.handleRuns(args, agentID, userID) + return t.handleRuns(ctx, args, agentID, userID) default: return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } @@ -178,9 +178,9 @@ func (t *CronTool) handleStatus() *Result { return NewResult(string(data)) } -func (t *CronTool) handleList(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleList(ctx context.Context, args map[string]any, agentID, userID string) *Result { includeDisabled, _ := args["includeDisabled"].(bool) - jobs := t.cronStore.ListJobs(includeDisabled, agentID, userID) + jobs := t.cronStore.ListJobs(ctx, includeDisabled, agentID, userID) result := map[string]any{ "jobs": jobs, @@ -288,7 +288,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, agentID = explicit } - job, err := t.cronStore.AddJob(name, schedule, message, deliver, channel, to, agentID, userID) + job, err := t.cronStore.AddJob(ctx, name, schedule, message, deliver, channel, to, agentID, userID) if err != nil { return ErrorResult(fmt.Sprintf("failed to create cron job: %v", err)) } @@ -296,7 +296,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, // Set wake_heartbeat if requested (triggers heartbeat after cron job completes) if wh, _ := jobObj["wake_heartbeat"].(bool); wh { wakeTrue := true - if updated, uErr := t.cronStore.UpdateJob(job.ID, store.CronJobPatch{WakeHeartbeat: &wakeTrue}); uErr == nil { + if updated, uErr := t.cronStore.UpdateJob(ctx, job.ID, store.CronJobPatch{WakeHeartbeat: &wakeTrue}); uErr == nil { job = updated } } @@ -307,8 +307,8 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, // checkJobOwnership validates that the job belongs to the current agent+user scope. // When agentID/userID is empty, all jobs are accessible. -func (t *CronTool) checkJobOwnership(jobID, agentID, userID string) (*store.CronJob, *Result) { - job, ok := t.cronStore.GetJob(jobID) +func (t *CronTool) checkJobOwnership(ctx context.Context, jobID, agentID, userID string) (*store.CronJob, *Result) { + job, ok := t.cronStore.GetJob(ctx, jobID) if !ok { return nil, ErrorResult(fmt.Sprintf("job %s not found", jobID)) } @@ -324,13 +324,13 @@ func (t *CronTool) checkJobOwnership(jobID, agentID, userID string) (*store.Cron return job, nil } -func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for update action") } - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } @@ -351,7 +351,7 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re } } - job, err := t.cronStore.UpdateJob(jobID, patch) + job, err := t.cronStore.UpdateJob(ctx, jobID, patch) if err != nil { return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err)) } @@ -360,17 +360,17 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re return NewResult(string(data)) } -func (t *CronTool) handleRemove(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleRemove(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for remove action") } - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } - if err := t.cronStore.RemoveJob(jobID); err != nil { + if err := t.cronStore.RemoveJob(ctx, jobID); err != nil { return ErrorResult(fmt.Sprintf("failed to remove cron job: %v", err)) } @@ -378,20 +378,20 @@ func (t *CronTool) handleRemove(args map[string]any, agentID, userID string) *Re return NewResult(string(data)) } -func (t *CronTool) handleRun(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleRun(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for run action") } - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } runMode, _ := args["runMode"].(string) force := runMode == "force" - ran, reason, err := t.cronStore.RunJob(jobID, force) + ran, reason, err := t.cronStore.RunJob(ctx, jobID, force) if err != nil { return ErrorResult(fmt.Sprintf("failed to run cron job: %v", err)) } @@ -407,12 +407,12 @@ func (t *CronTool) handleRun(args map[string]any, agentID, userID string) *Resul return NewResult(string(data)) } -func (t *CronTool) handleRuns(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleRuns(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) // Validate ownership if a specific job is requested if jobID != "" { - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } } @@ -422,7 +422,7 @@ func (t *CronTool) handleRuns(args map[string]any, agentID, userID string) *Resu limit = int(v) } - entries, total := t.cronStore.GetRunLog(jobID, limit, 0) + entries, total := t.cronStore.GetRunLog(ctx, jobID, limit, 0) result := map[string]any{ "entries": entries, diff --git a/internal/tools/dynamic_loader.go b/internal/tools/dynamic_loader.go deleted file mode 100644 index 3e504c2b..00000000 --- a/internal/tools/dynamic_loader.go +++ /dev/null @@ -1,95 +0,0 @@ -package tools - -import ( - "context" - "log/slog" - "sync" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// DynamicToolLoader loads custom tools from the database and registers them into tool registries. -type DynamicToolLoader struct { - store store.CustomToolStore - workspace string - mu sync.Mutex - // Track names of globally registered custom tools (for reload/unregister). - globalNames map[string]bool -} - -// NewDynamicToolLoader creates a loader for custom tools from the database. -func NewDynamicToolLoader(s store.CustomToolStore, workspace string) *DynamicToolLoader { - return &DynamicToolLoader{ - store: s, - workspace: workspace, - globalNames: make(map[string]bool), - } -} - -// LoadGlobal loads all global custom tools (agent_id IS NULL) and registers them into the registry. -// Skips tools whose names collide with existing built-in or MCP tools. -func (l *DynamicToolLoader) LoadGlobal(ctx context.Context, reg *Registry) error { - l.mu.Lock() - defer l.mu.Unlock() - - defs, err := l.store.ListGlobal(ctx) - if err != nil { - return err - } - - registered := 0 - for _, def := range defs { - if _, exists := reg.Get(def.Name); exists { - slog.Warn("custom_tools: skipping global tool (name collision with built-in/MCP)", - "tool", def.Name) - continue - } - reg.Register(NewDynamicTool(def, l.workspace)) - l.globalNames[def.Name] = true - registered++ - } - - if registered > 0 { - slog.Info("custom_tools: loaded global tools", "count", registered) - } - return nil -} - -// LoadForAgent loads per-agent custom tools and returns a cloned registry with them added. -// Returns nil if the agent has no custom tools (caller should use global registry). -func (l *DynamicToolLoader) LoadForAgent(ctx context.Context, globalReg *Registry, agentID uuid.UUID) (*Registry, error) { - defs, err := l.store.ListByAgent(ctx, agentID) - if err != nil { - return nil, err - } - if len(defs) == 0 { - return nil, nil // no per-agent tools - } - - clone := globalReg.Clone() - for _, def := range defs { - clone.Register(NewDynamicTool(def, l.workspace)) - } - - slog.Debug("custom_tools: loaded per-agent tools", "agent_id", agentID, "count", len(defs)) - return clone, nil -} - -// ReloadGlobal unregisters all previously loaded global tools and re-loads from DB. -// Used on cache invalidation events. -func (l *DynamicToolLoader) ReloadGlobal(ctx context.Context, reg *Registry) { - l.mu.Lock() - // Unregister old global custom tools - for name := range l.globalNames { - reg.Unregister(name) - } - l.globalNames = make(map[string]bool) - l.mu.Unlock() - - // Re-load (LoadGlobal acquires its own lock) - if err := l.LoadGlobal(ctx, reg); err != nil { - slog.Warn("custom_tools: failed to reload global tools", "error", err) - } -} diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go deleted file mode 100644 index 58e19ae1..00000000 --- a/internal/tools/dynamic_tool.go +++ /dev/null @@ -1,133 +0,0 @@ -package tools - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" - "time" - - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// DynamicTool wraps a CustomToolDef from the database and implements the Tool interface. -// Command templates use {{.key}} placeholders — all LLM-provided values are shell-escaped. -type DynamicTool struct { - def store.CustomToolDef - workspace string - params map[string]any -} - -// NewDynamicTool creates a Tool from a DB-stored custom tool definition. -func NewDynamicTool(def store.CustomToolDef, workspace string) *DynamicTool { - var params map[string]any - if len(def.Parameters) > 0 { - json.Unmarshal(def.Parameters, ¶ms) - } - if params == nil { - params = map[string]any{ - "type": "object", - "properties": map[string]any{}, - } - } - return &DynamicTool{def: def, workspace: workspace, params: params} -} - -func (t *DynamicTool) Name() string { return t.def.Name } -func (t *DynamicTool) Description() string { return t.def.Description } -func (t *DynamicTool) Parameters() map[string]any { return t.params } - -func (t *DynamicTool) Execute(ctx context.Context, args map[string]any) *Result { - // Render command template with shell-escaped args - command := renderCommand(t.def.Command, args) - - // Check deny patterns (uses all defaults for dynamic tools — no per-agent override) - for _, pattern := range DefaultDenyPatterns() { - if pattern.MatchString(command) { - return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) - } - } - - // Timeout - timeout := time.Duration(t.def.TimeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 60 * time.Second - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Working directory — per-user workspace from context, fallback to tool's workspace. - // Explicit WorkingDir on the tool definition still overrides everything. - cwd := ToolWorkspaceFromCtx(ctx) - if cwd == "" { - cwd = t.workspace - } - if t.def.WorkingDir != "" { - cwd = t.def.WorkingDir - } - - cmd := exec.CommandContext(ctx, "sh", "-c", command) - cmd.Dir = cwd - - // Decrypt and apply env vars - if len(t.def.Env) > 0 { - var envMap map[string]string - if json.Unmarshal(t.def.Env, &envMap) == nil { - for k, v := range envMap { - cmd.Env = append(cmd.Env, k+"="+v) - } - } - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - var result string - if stdout.Len() > 0 { - result = stdout.String() - } - if stderr.Len() > 0 { - if result != "" { - result += "\n" - } - result += "STDERR:\n" + stderr.String() - } - - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return ErrorResult(fmt.Sprintf("command timed out after %s", timeout)) - } - if result == "" { - result = err.Error() - } - return ErrorResult(result) - } - - if result == "" { - result = "(command completed with no output)" - } - - return SilentResult(result) -} - -// renderCommand replaces {{.key}} placeholders with shell-escaped arg values. -// Uses simple string replacement (NOT Go text/template) for security. -func renderCommand(tmpl string, args map[string]any) string { - result := tmpl - for key, val := range args { - placeholder := "{{." + key + "}}" - escaped := shellEscape(fmt.Sprint(val)) - result = strings.ReplaceAll(result, placeholder, escaped) - } - return result -} - -// shellEscape wraps a value in single quotes, escaping embedded single quotes. -func shellEscape(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} diff --git a/internal/tools/media_provider_chain.go b/internal/tools/media_provider_chain.go index 9e9d04f0..c706af19 100644 --- a/internal/tools/media_provider_chain.go +++ b/internal/tools/media_provider_chain.go @@ -84,7 +84,7 @@ func ResolveMediaProviderChain( } // 3. Hardcoded default chain — use first available provider - return buildDefaultChain(defaultPriority, defaultModels, registry) + return buildDefaultChain(ctx, defaultPriority, defaultModels, registry) } // parseChainSettings parses the settings JSON into a chain, handling both new @@ -116,13 +116,14 @@ func parseChainSettings(raw []byte, defaultModels map[string]string) []MediaProv // buildDefaultChain creates a chain from the hardcoded priority list, // including only providers that are currently registered. func buildDefaultChain( + ctx context.Context, priority []string, defaultModels map[string]string, registry *providers.Registry, ) []MediaProviderEntry { var chain []MediaProviderEntry for _, name := range priority { - if _, err := registry.Get(name); err == nil { + if _, err := registry.Get(ctx, name); err == nil { entry := MediaProviderEntry{ Provider: name, Model: defaultModels[name], @@ -162,7 +163,7 @@ func ExecuteWithChain( var lastErr error for _, entry := range chain { - p, err := registry.Get(entry.Provider) + p, err := registry.Get(ctx, entry.Provider) if err != nil { slog.Warn("media_chain: provider not found, skipping", "provider", entry.Provider, "error", err) diff --git a/internal/tools/read_audio_resolve.go b/internal/tools/read_audio_resolve.go index 63cab3cf..2adc823a 100644 --- a/internal/tools/read_audio_resolve.go +++ b/internal/tools/read_audio_resolve.go @@ -48,9 +48,17 @@ func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (p ref = &refs[len(refs)-1] } - p, err := t.mediaLoader.LoadPath(ref.ID) - if err != nil { - return "", "", fmt.Errorf("audio file not found: %v", err) + // Prefer persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" { + var err error + if t.mediaLoader == nil { + return "", "", fmt.Errorf("no media storage configured") + } + p, err = t.mediaLoader.LoadPath(ref.ID) + if err != nil { + return "", "", fmt.Errorf("audio file not found: %v", err) + } } mime = ref.MimeType @@ -99,7 +107,7 @@ func (t *ReadAudioTool) callProvider(ctx context.Context, cp credentialProvider, } // Other providers: try standard Chat API with base64 audio as image_url (best effort). - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/read_document_resolve.go b/internal/tools/read_document_resolve.go index 67269a09..8c899718 100644 --- a/internal/tools/read_document_resolve.go +++ b/internal/tools/read_document_resolve.go @@ -13,10 +13,6 @@ import ( // resolveDocumentFile finds the document file path from context MediaRefs. func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID string) (path, mime string, err error) { - if t.mediaLoader == nil { - return "", "", fmt.Errorf("no media storage configured — cannot access document files") - } - refs := MediaDocRefsFromCtx(ctx) if len(refs) == 0 { return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.") @@ -39,9 +35,17 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID stri ref = &refs[len(refs)-1] } - p, err := t.mediaLoader.LoadPath(ref.ID) - if err != nil { - return "", "", fmt.Errorf("document file not found: %v", err) + // Prefer persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" { + var err error + if t.mediaLoader == nil { + return "", "", fmt.Errorf("no media storage configured") + } + p, err = t.mediaLoader.LoadPath(ref.ID) + if err != nil { + return "", "", fmt.Errorf("document file not found: %v", err) + } } // Determine MIME type: prefer ref's stored MIME, fall back to extension. @@ -75,7 +79,7 @@ func (t *ReadDocumentTool) callProvider(ctx context.Context, cp credentialProvid } // Other providers: use standard Chat API with document as base64 image_url. - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/read_image.go b/internal/tools/read_image.go index d0b92aaf..5b4fee4d 100644 --- a/internal/tools/read_image.go +++ b/internal/tools/read_image.go @@ -125,7 +125,7 @@ func (t *ReadImageTool) callProvider(ctx context.Context, cp credentialProvider, images, _ := params["images"].([]providers.ImageContent) // Get the full provider for Chat() access - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/read_video_resolve.go b/internal/tools/read_video_resolve.go index 85bb3682..8a67d925 100644 --- a/internal/tools/read_video_resolve.go +++ b/internal/tools/read_video_resolve.go @@ -37,9 +37,17 @@ func (t *ReadVideoTool) resolveVideoFile(ctx context.Context, mediaID string) (p ref = &refs[len(refs)-1] } - p, err := t.mediaLoader.LoadPath(ref.ID) - if err != nil { - return "", "", fmt.Errorf("video file not found: %v", err) + // Prefer persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" { + var err error + if t.mediaLoader == nil { + return "", "", fmt.Errorf("no media storage configured") + } + p, err = t.mediaLoader.LoadPath(ref.ID) + if err != nil { + return "", "", fmt.Errorf("video file not found: %v", err) + } } mime = ref.MimeType @@ -70,7 +78,7 @@ func (t *ReadVideoTool) callProvider(ctx context.Context, cp credentialProvider, } // Other providers: try standard Chat API with base64 as image_url (best effort). - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/sessions.go b/internal/tools/sessions.go index 6c93fe91..42c8ae80 100644 --- a/internal/tools/sessions.go +++ b/internal/tools/sessions.go @@ -59,7 +59,7 @@ func (t *SessionsListTool) Execute(ctx context.Context, args map[string]any) *Re } agentID := resolveAgentIDString(ctx) - sessions := t.sessions.List(agentID) + sessions := t.sessions.List(ctx, agentID) // Filter by active_minutes if activeMinutes > 0 { @@ -148,7 +148,7 @@ func (t *SessionStatusTool) Execute(ctx context.Context, args map[string]any) *R return ErrorResult("access denied: session belongs to a different agent") } - data := t.sessions.GetOrCreate(sessionKey) + data := t.sessions.GetOrCreate(ctx, sessionKey) var lines []string lines = append(lines, fmt.Sprintf("Session: %s", data.Key)) diff --git a/internal/tools/sessions_history.go b/internal/tools/sessions_history.go index 5ae5823f..0b6855dd 100644 --- a/internal/tools/sessions_history.go +++ b/internal/tools/sessions_history.go @@ -76,7 +76,7 @@ func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]any) return ErrorResult("access denied: session belongs to a different agent") } - history := t.sessions.GetHistory(sessionKey) + history := t.sessions.GetHistory(ctx, sessionKey) if history == nil { return SilentResult(`{"session_key":"` + sessionKey + `","messages":[],"count":0}`) } diff --git a/internal/tools/sessions_send.go b/internal/tools/sessions_send.go index c9134843..b8ea77f7 100644 --- a/internal/tools/sessions_send.go +++ b/internal/tools/sessions_send.go @@ -72,10 +72,10 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re // Resolve by label if needed if sessionKey == "" && label != "" { - sessions := t.sessions.List(agentID) + sessions := t.sessions.List(ctx, agentID) for _, s := range sessions { // Check if label matches by loading session data - data := t.sessions.GetOrCreate(s.Key) + data := t.sessions.GetOrCreate(ctx, s.Key) if data.Label == label { sessionKey = s.Key break @@ -98,6 +98,7 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re ChatID: sessionKey, Content: message, PeerKind: "direct", + TenantID: store.TenantIDFromContext(ctx), }) return SilentResult(fmt.Sprintf(`{"status":"accepted","session_key":"%s"}`, sessionKey)) diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 706b5179..73272357 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -198,14 +198,23 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { } } - // Use per-user workspace from context if available, fallback to struct field + // Use per-user workspace from context if available, fallback to struct field. + // The context workspace is tenant-scoped; t.workspace is the global (master) workspace. cwd := ToolWorkspaceFromCtx(ctx) if cwd == "" { cwd = t.workspace } if wd, _ := args["working_dir"].(string); wd != "" { if effectiveRestrict(ctx, t.restrict) { - resolved, err := resolvePath(wd, t.workspace, true) + // Validate working_dir against the tenant-scoped workspace (not the + // global workspace) so non-master tenants can't escape their scope. + // Also allow team workspace as a valid target (same as filesystem tools). + wsBase := ToolWorkspaceFromCtx(ctx) + if wsBase == "" { + wsBase = t.workspace + } + allowed := allowedWithTeamWorkspace(ctx, nil) + resolved, err := resolvePathWithAllowed(wd, wsBase, true, allowed) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go index 1d088c17..7f9e8eec 100644 --- a/internal/tools/skill_manage.go +++ b/internal/tools/skill_manage.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" @@ -22,13 +23,21 @@ import ( // Complements publish_skill (directory-based) with a content-string interface // so agents can create/patch/delete skills without pre-writing files to disk. type SkillManageTool struct { - skills *pg.PGSkillStore - base string // skills-store/ base directory - loader *skills.Loader // cache invalidation + skills *pg.PGSkillStore + base string // skills-store/ base directory (master tenant) + dataDir string // parent data dir for tenant-scoped skill paths + loader *skills.Loader // cache invalidation } -func NewSkillManageTool(skills *pg.PGSkillStore, baseDir string, loader *skills.Loader) *SkillManageTool { - return &SkillManageTool{skills: skills, base: baseDir, loader: loader} +func NewSkillManageTool(skills *pg.PGSkillStore, baseDir, dataDir string, loader *skills.Loader) *SkillManageTool { + return &SkillManageTool{skills: skills, base: baseDir, dataDir: dataDir, loader: loader} +} + +// tenantSkillsDir returns the skills-store directory scoped to the calling agent's tenant. +func (t *SkillManageTool) tenantSkillsDir(ctx context.Context) string { + tid := store.TenantIDFromContext(ctx) + slug := store.TenantSlugFromContext(ctx) + return config.TenantSkillsStoreDir(t.dataDir, tid, slug) } func (t *SkillManageTool) Name() string { return "skill_manage" } @@ -124,9 +133,9 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) } - // Version + destination + // Version + destination (tenant-scoped) version := t.skills.GetNextVersion(slug) - destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", version)) + destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", version)) if err := os.MkdirAll(destDir, 0755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skill directory: %v", err)) } @@ -217,7 +226,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) return ErrorResult("find is required for action=patch") } - info, ok := t.skills.GetSkill(slug) + info, ok := t.skills.GetSkill(ctx, slug) if !ok { return ErrorResult(fmt.Sprintf("skill %q not found or archived", slug)) } @@ -227,7 +236,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) // Ownership check: only the skill owner can patch userID := store.UserIDFromContext(ctx) - if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != userID { + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != userID { return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) } @@ -254,7 +263,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) return ErrorResult(fmt.Sprintf("failed to lock version: %v", lockErr)) } defer commitLock() //nolint:errcheck - destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", newVer)) + destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", newVer)) if err := os.MkdirAll(destDir, 0755); err != nil { return ErrorResult(fmt.Sprintf("failed to create new version directory: %v", err)) } @@ -281,7 +290,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) if err != nil { return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err)) } - if err := t.skills.UpdateSkill(skillID, map[string]any{ + if err := t.skills.UpdateSkill(ctx, skillID, map[string]any{ "version": newVer, "file_path": destDir, "file_size": fileSize, @@ -307,7 +316,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any return ErrorResult("slug is required for action=delete") } - info, ok := t.skills.GetSkill(slug) + info, ok := t.skills.GetSkill(ctx, slug) if !ok { return ErrorResult(fmt.Sprintf("skill %q not found or already archived", slug)) } @@ -317,17 +326,18 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any // Ownership check: only the skill owner can delete deleteUserID := store.UserIDFromContext(ctx) - if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != deleteUserID { + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != deleteUserID { return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) } // Soft-delete on disk: move to .trash/. - trashDir := filepath.Join(t.base, ".trash") + skillsDir := t.tenantSkillsDir(ctx) + trashDir := filepath.Join(skillsDir, ".trash") if err := os.MkdirAll(trashDir, 0755); err != nil { slog.Warn("skill_manage: failed to create .trash dir", "error", err) } else { timestamp := fmt.Sprintf("%d", time.Now().Unix()) - src := filepath.Join(t.base, slug) + src := filepath.Join(skillsDir, slug) dst := filepath.Join(trashDir, slug+"."+timestamp) if err := os.Rename(src, dst); err != nil { // Cross-device rename fails on some setups — log and continue (DB archive is primary) @@ -340,7 +350,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any if err != nil { return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err)) } - if err := t.skills.DeleteSkill(skillID); err != nil { + if err := t.skills.DeleteSkill(ctx, skillID); err != nil { return ErrorResult(fmt.Sprintf("failed to archive skill in database: %v", err)) } diff --git a/internal/tools/skill_search.go b/internal/tools/skill_search.go index 37f0f636..8d659e52 100644 --- a/internal/tools/skill_search.go +++ b/internal/tools/skill_search.go @@ -33,7 +33,7 @@ type SkillSearchTool struct { func NewSkillSearchTool(loader *skills.Loader) *SkillSearchTool { idx := skills.NewIndex() t := &SkillSearchTool{index: idx, loader: loader} - t.rebuildIndex() + t.rebuildIndex(store.WithCrossTenant(context.Background())) return t } @@ -49,18 +49,18 @@ func (t *SkillSearchTool) SetSkillAccessStore(sas store.SkillAccessStore) { } // rebuildIndex refreshes the BM25 index from the current skill set. -func (t *SkillSearchTool) rebuildIndex() { - allSkills := t.loader.ListSkills() +func (t *SkillSearchTool) rebuildIndex(ctx context.Context) { + allSkills := t.loader.ListSkills(ctx) t.index.Build(allSkills) t.lastVersion = t.loader.Version() slog.Info("skill_search index rebuilt", "docs", len(allSkills), "version", t.lastVersion) } // ensureIndex rebuilds the BM25 index if skills have changed since last build. -func (t *SkillSearchTool) ensureIndex() { +func (t *SkillSearchTool) ensureIndex(ctx context.Context) { current := t.loader.Version() if current > t.lastVersion { - t.rebuildIndex() + t.rebuildIndex(ctx) } } @@ -99,7 +99,7 @@ func (t *SkillSearchTool) Execute(ctx context.Context, args map[string]any) *Res } // Lazy rebuild: check if skills changed since last index build - t.ensureIndex() + t.ensureIndex(ctx) // BM25 search (always available) bm25Results := t.index.Search(query, maxResults*2) diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index a52c01a5..3e2ea0dd 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -62,6 +62,7 @@ type SubagentTask struct { CreatedAt int64 `json:"createdAt"` CompletedAt int64 `json:"completedAt,omitempty"` Media []bus.MediaFile `json:"-"` // media files from tool results + OriginTenantID uuid.UUID `json:"-"` // parent's tenant for announce routing OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking OriginRootSpanID uuid.UUID `json:"-"` // parent agent's root span ID cancelFunc context.CancelFunc `json:"-"` // per-task context cancel @@ -274,6 +275,7 @@ func (sm *SubagentManager) Spawn( OriginLocalKey: ToolLocalKeyFromCtx(ctx), OriginUserID: store.UserIDFromContext(ctx), OriginSessionKey: ToolSessionKeyFromCtx(ctx), + OriginTenantID: store.TenantIDFromContext(ctx), OriginTraceID: tracing.TraceIDFromContext(ctx), OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), CreatedAt: time.Now().UnixMilli(), diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go index 9c35f400..74316884 100644 --- a/internal/tools/subagent_exec.go +++ b/internal/tools/subagent_exec.go @@ -40,6 +40,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call OriginLocalKey: task.OriginLocalKey, OriginUserID: task.OriginUserID, OriginSessionKey: task.OriginSessionKey, + OriginTenantID: task.OriginTenantID, ParentAgent: task.ParentID, OriginTraceID: task.OriginTraceID.String(), OriginRootSpanID: task.OriginRootSpanID.String(), @@ -75,6 +76,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call ChatID: task.OriginChatID, Content: announceContent, UserID: task.OriginUserID, + TenantID: task.OriginTenantID, Metadata: announceMeta, Media: task.Media, }) @@ -167,7 +169,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) activeProvider := sm.provider if sm.providerReg != nil { if parentProviderName := ParentProviderFromCtx(ctx); parentProviderName != "" { - if p, err := sm.providerReg.Get(parentProviderName); err == nil { + if p, err := sm.providerReg.Get(ctx, parentProviderName); err == nil { activeProvider = p } } diff --git a/internal/tools/subagent_spawn_tool.go b/internal/tools/subagent_spawn_tool.go index 5cecff88..6a309303 100644 --- a/internal/tools/subagent_spawn_tool.go +++ b/internal/tools/subagent_spawn_tool.go @@ -5,6 +5,9 @@ import ( "fmt" "strings" "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // SpawnTool spawns subagent clones to handle tasks in the background. @@ -97,6 +100,15 @@ func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Resu agentKey, agentKey)) } + // Validate tenant isolation: non-cross-tenant callers must have a tenant in context. + // Self-clone subagents inherit caller's context (WithoutCancel), so tenant propagates automatically. + if !store.IsCrossTenant(ctx) { + callerTenant := store.TenantIDFromContext(ctx) + if callerTenant == uuid.Nil { + return ErrorResult("spawn requires tenant context: no tenant ID found in request context") + } + } + task, _ := args["task"].(string) if task == "" { return ErrorResult("task parameter is required") diff --git a/internal/tools/subagent_tracing.go b/internal/tools/subagent_tracing.go index 2de91f89..1f5f3254 100644 --- a/internal/tools/subagent_tracing.go +++ b/internal/tools/subagent_tracing.go @@ -43,6 +43,10 @@ func (sm *SubagentManager) emitLLMSpanStart(ctx context.Context, start time.Time span.ParentSpanID = &parentID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } if collector.Verbose() && len(messages) > 0 { if b, err := json.Marshal(messages); err == nil { span.InputPreview = truncate(string(b), 100000) @@ -131,6 +135,10 @@ func (sm *SubagentManager) emitToolSpanStart(ctx context.Context, start time.Tim span.ParentSpanID = &parentID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) return spanID } @@ -198,6 +206,10 @@ func (sm *SubagentManager) emitSubagentSpanStart(ctx context.Context, spanID uui span.ParentSpanID = &parentSpanID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) } diff --git a/internal/tools/team_tasks_followup.go b/internal/tools/team_tasks_followup.go index 274a7df2..e2a09378 100644 --- a/internal/tools/team_tasks_followup.go +++ b/internal/tools/team_tasks_followup.go @@ -117,8 +117,11 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * if task.TeamID != team.ID { return ErrorResult("task does not belong to your team") } - if task.Status != store.TeamTaskStatusStale && task.Status != store.TeamTaskStatusFailed { - return ErrorResult(fmt.Sprintf("retry only works on stale or failed tasks (current status: %s)", task.Status)) + switch task.Status { + case store.TeamTaskStatusStale, store.TeamTaskStatusFailed, store.TeamTaskStatusCompleted: + // OK — can retry/reopen these statuses + default: + return ErrorResult(fmt.Sprintf("retry only works on completed, stale, or failed tasks (current status: %s)", task.Status)) } if task.OwnerAgentID == nil { return ErrorResult("task has no assignee — assign it first via update") @@ -133,7 +136,7 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * return ErrorResult("failed to retry task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, @@ -151,5 +154,6 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * // Dispatch immediately (retry is an explicit action, not during a turn). t.manager.dispatchTaskToAgent(ctx, task, team.ID, *task.OwnerAgentID) - return NewResult(fmt.Sprintf("Task %s retried and dispatched to %s.", taskID, t.manager.agentKeyFromID(ctx, *task.OwnerAgentID))) + assignee := t.manager.agentKeyFromID(ctx, *task.OwnerAgentID) + return NewResult(fmt.Sprintf("Task #%d \"%s\" (id: %s) retried and dispatched to %s. The assignee will receive the task with your recent comments.", task.TaskNumber, task.Subject, taskID, assignee)) } diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index 06f6f711..2d38a2eb 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -25,7 +25,7 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) * } ownerKey := t.manager.agentKeyFromID(ctx, agentID) - t.manager.broadcastTeamEvent(protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusInProgress, @@ -82,7 +82,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any taskNumber = completedTask.TaskNumber taskSubject = completedTask.Subject } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: taskNumber, @@ -135,7 +135,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) return ErrorResult("failed to cancel task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusCancelled, @@ -181,7 +181,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) } ownerKey := t.manager.agentKeyFromID(ctx, agentID) - t.manager.broadcastTeamEvent(protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusInReview, @@ -245,7 +245,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) newStatus = approved.Status } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Subject: task.Subject, @@ -313,7 +313,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) return ErrorResult("failed to reject task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Subject: task.Subject, diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 863434eb..513e0e3a 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "strings" "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tracing" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -98,7 +100,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) if assigneeKey == "" { return ErrorResult("assignee is required — specify which team member should handle this task") } - assigneeID, err := t.manager.resolveAgentByKey(assigneeKey) + assigneeID, err := t.manager.resolveAgentByKey(ctx, assigneeKey) if err != nil { return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err)) } @@ -140,10 +142,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) wsChat = "" } - // Compute the team workspace directory so member agents write files to the - // shared team folder instead of their own personal workspace. + // Compute the team workspace directory (tenant-scoped) so member agents + // write files to the shared team folder instead of their own personal workspace. taskMeta := make(map[string]any) - if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil { + tenantBase := config.TenantWorkspace(t.manager.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)) + if teamWsDir, err := WorkspaceDir(tenantBase, team.ID, wsChat); err == nil { taskMeta["team_workspace"] = teamWsDir } // Auto-collect media files from current run to team workspace. @@ -231,7 +234,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) } agentKey := t.manager.agentKeyFromID(ctx, agentID) - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), Subject: subject, @@ -253,7 +256,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) if err := t.manager.teamStore.AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil { slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err) } else { - t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, @@ -314,7 +317,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) return ErrorResult("failed to add comment: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, @@ -328,7 +331,19 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) ActorID: t.manager.agentKeyFromID(ctx, agentID), }) - return NewResult(fmt.Sprintf("Comment added to task %s.", taskID)) + isLead := agentID == team.LeadAgentID + msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID) + switch { + case isLead && task.Status == store.TeamTaskStatusInProgress: + msg += " Note: the assignee is currently working and won't see this comment during execution. To redirect, wait for completion then use retry action with this task_id." + case isLead && task.Status == store.TeamTaskStatusCompleted: + msg += " Task is completed. Use retry action with this task_id to reopen and re-dispatch with your feedback." + case isLead && task.Status == store.TeamTaskStatusFailed: + msg += " Task failed. Use retry action with this task_id to re-dispatch with your feedback." + case !isLead && task.OwnerAgentID != nil && *task.OwnerAgentID == agentID: + msg += " Your comment will be included in the task report sent to the leader. Continue working on the task." + } + return NewResult(msg) } func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result { @@ -376,7 +391,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any if task.OwnerAgentID != nil { ownerKey = t.manager.agentKeyFromID(ctx, *task.OwnerAgentID) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, @@ -410,6 +425,14 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) return ErrorResult("path is required for attach action") } + // Resolve to absolute path within team workspace. + if !filepath.IsAbs(filePath) { + if ws := ToolTeamWorkspaceFromCtx(ctx); ws != "" { + filePath = filepath.Join(ws, filePath) + } + } + filePath = filepath.Clean(filePath) + // Verify task belongs to team. task, err := t.manager.teamStore.GetTask(ctx, taskID) if err != nil { @@ -430,7 +453,7 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) return ErrorResult("failed to attach file: " + err.Error()) } - return NewResult(fmt.Sprintf("File attached to task %s.", taskID)) + return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)) } func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result { @@ -511,7 +534,7 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) return ErrorResult("failed to update task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Subject: task.Subject, @@ -524,5 +547,5 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) ActorID: t.manager.agentKeyFromID(ctx, agentID), }) - return NewResult(fmt.Sprintf("Task %s updated.", taskID)) + return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID)) } diff --git a/internal/tools/team_tasks_read.go b/internal/tools/team_tasks_read.go index cacf5c5d..1b711a66 100644 --- a/internal/tools/team_tasks_read.go +++ b/internal/tools/team_tasks_read.go @@ -188,6 +188,13 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R tasks = tasks[:listPageSize] } + // Pre-warm agent cache to avoid N+1 queries for display names. + agentKeys := make([]string, 0, len(tasks)*2) + for _, task := range tasks { + agentKeys = append(agentKeys, task.OwnerAgentKey, task.CreatedByAgentKey) + } + t.manager.preWarmAgentKeyCache(ctx, agentKeys) + items := make([]taskListItem, 0, len(tasks)) for _, task := range tasks { items = append(items, t.toListItem(ctx, task)) @@ -261,12 +268,24 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re } } + // Pre-warm cache for task owner + creator display names. + t.manager.preWarmAgentKeyCache(ctx, []string{task.OwnerAgentKey, task.CreatedByAgentKey}) + detail := t.toDetailItem(ctx, task) // Load and slim comments/events/attachments resp := map[string]any{"task": detail} if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 { + // Pre-warm agent cache to avoid N+1 queries for comment agent keys. + commentAgentIDs := make([]uuid.UUID, 0, len(comments)) + for _, c := range comments { + if c.AgentID != nil { + commentAgentIDs = append(commentAgentIDs, *c.AgentID) + } + } + t.manager.preWarmAgentIDCache(ctx, commentAgentIDs) + slim := make([]slimComment, 0, len(comments)) for _, c := range comments { key := "" diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go index f116ea86..d2521129 100644 --- a/internal/tools/team_tool_cache.go +++ b/internal/tools/team_tool_cache.go @@ -104,6 +104,20 @@ func (m *TeamToolManager) InvalidateAgentCache() { m.agentKeyCache.Range(func(k, _ any) bool { m.agentKeyCache.Delete(k); return true }) } +// agentKeyCacheKey builds a tenant-scoped cache key for agentKeyCache. +// Agent keys (e.g. "my-agent") are unique per-tenant, not globally, +// so the cache key must include tenant to prevent cross-tenant pollution. +func agentKeyCacheKey(ctx context.Context, key string) string { + if store.IsCrossTenant(ctx) { + return "*:" + key + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return "*:" + key + } + return tid.String() + ":" + key +} + // cachedGetAgentByID returns agent data from cache or DB with TTL. func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) { if entry, ok := m.agentCache.Load(id); ok { @@ -120,18 +134,19 @@ func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) now := time.Now() e := &agentCacheEntry{agent: ag, cachedAt: now} m.agentCache.Store(id, e) - m.agentKeyCache.Store(ag.AgentKey, e) + m.agentKeyCache.Store(agentKeyCacheKey(ctx, ag.AgentKey), e) return ag, nil } // cachedGetAgentByKey returns agent data from cache or DB with TTL. func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) (*store.AgentData, error) { - if entry, ok := m.agentKeyCache.Load(key); ok { + ck := agentKeyCacheKey(ctx, key) + if entry, ok := m.agentKeyCache.Load(ck); ok { ce := entry.(*agentCacheEntry) if time.Since(ce.cachedAt) < teamCacheTTL { return ce.agent, nil } - m.agentKeyCache.Delete(key) + m.agentKeyCache.Delete(ck) } ag, err := m.agentStore.GetByKey(ctx, key) if err != nil { @@ -139,7 +154,7 @@ func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) ( } now := time.Now() e := &agentCacheEntry{agent: ag, cachedAt: now} - m.agentKeyCache.Store(key, e) + m.agentKeyCache.Store(ck, e) m.agentCache.Store(ag.ID, e) return ag, nil } @@ -155,9 +170,87 @@ func (m *TeamToolManager) cachedListMembers(ctx context.Context, teamID uuid.UUI return m.teamStore.ListMembers(ctx, teamID) } +// preWarmAgentKeyCache batch-fetches agents by key and populates the cache. +// Reduces N+1 queries when rendering task lists with agent display names. +func (m *TeamToolManager) preWarmAgentKeyCache(ctx context.Context, keys []string) { + // Deduplicate and filter already-cached keys. + unique := make(map[string]bool) + var missing []string + for _, k := range keys { + if k == "" || unique[k] { + continue + } + unique[k] = true + ck := agentKeyCacheKey(ctx, k) + if entry, ok := m.agentKeyCache.Load(ck); ok { + ce := entry.(*agentCacheEntry) + if time.Since(ce.cachedAt) < teamCacheTTL { + continue // still valid + } + } + missing = append(missing, k) + } + if len(missing) == 0 { + return + } + + agents, err := m.agentStore.GetByKeys(ctx, missing) + if err != nil { + slog.Debug("preWarmAgentKeyCache: batch fetch failed", "error", err) + return + } + now := time.Now() + for i := range agents { + ag := &agents[i] + e := &agentCacheEntry{agent: ag, cachedAt: now} + ck := agentKeyCacheKey(ctx, ag.AgentKey) + m.agentKeyCache.Store(ck, e) + m.agentCache.Store(ag.ID, e) + } +} + +// preWarmAgentIDCache batch-fetches agents by UUID and populates the cache. +// Reduces N+1 queries when rendering task comments with agent keys. +func (m *TeamToolManager) preWarmAgentIDCache(ctx context.Context, ids []uuid.UUID) { + // Deduplicate and filter already-cached IDs. + seen := make(map[uuid.UUID]bool) + var missing []uuid.UUID + for _, id := range ids { + if id == uuid.Nil || seen[id] { + continue + } + seen[id] = true + if entry, ok := m.agentCache.Load(id); ok { + ce := entry.(*agentCacheEntry) + if time.Since(ce.cachedAt) < teamCacheTTL { + continue + } + } + missing = append(missing, id) + } + if len(missing) == 0 { + return + } + + agents, err := m.agentStore.GetByIDs(ctx, missing) + if err != nil { + slog.Debug("preWarmAgentIDCache: batch fetch failed", "error", err) + return + } + now := time.Now() + for i := range agents { + ag := &agents[i] + e := &agentCacheEntry{agent: ag, cachedAt: now} + m.agentCache.Store(ag.ID, e) + ck := agentKeyCacheKey(ctx, ag.AgentKey) + m.agentKeyCache.Store(ck, e) + } +} + // resolveAgentByKey looks up an agent by key and returns its UUID. -func (m *TeamToolManager) resolveAgentByKey(key string) (uuid.UUID, error) { - ag, err := m.cachedGetAgentByKey(context.Background(), key) +// Uses the caller's tenant-scoped context for isolation. +func (m *TeamToolManager) resolveAgentByKey(ctx context.Context, key string) (uuid.UUID, error) { + ag, err := m.cachedGetAgentByKey(ctx, key) if err != nil { return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err) } diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index cc6ccf4d..aed8c5fd 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -166,6 +166,7 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T ChatID: teamID.String(), Content: content, UserID: originUserID, + TenantID: store.TenantIDFromContext(ctx), AgentID: ag.AgentKey, Metadata: meta, }) { @@ -304,7 +305,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui continue } dispatched[ownerID] = true - m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, diff --git a/internal/tools/team_tool_helpers.go b/internal/tools/team_tool_helpers.go index 7e533829..fbb42a0a 100644 --- a/internal/tools/team_tool_helpers.go +++ b/internal/tools/team_tool_helpers.go @@ -13,14 +13,12 @@ import ( ) // broadcastTeamEvent sends a real-time event via the message bus for team activity visibility. -func (m *TeamToolManager) broadcastTeamEvent(name string, payload any) { +// Includes tenant_id from context for proper WS event filtering. +func (m *TeamToolManager) broadcastTeamEvent(ctx context.Context, name string, payload any) { if m.msgBus == nil { return } - m.msgBus.Broadcast(bus.Event{ - Name: name, - Payload: payload, - }) + bus.BroadcastForTenant(m.msgBus, name, store.TenantIDFromContext(ctx), payload) } // resolveTeamRole returns the calling agent's role in the team. @@ -194,7 +192,7 @@ func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store. return ErrorResult("failed to create escalation task: " + err.Error()) } - m.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), Subject: subject, diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index d7b3c469..390f8bd4 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -59,7 +59,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil { slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err) } - m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusFailed, @@ -91,7 +91,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err) continue } - m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, @@ -133,7 +133,7 @@ func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID, if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil { slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err) } - m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: id.String(), Status: store.TeamTaskStatusFailed, @@ -177,6 +177,7 @@ func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uui ChatID: chatID, AgentID: leadAgent.AgentKey, UserID: team.CreatedBy, + TenantID: store.TenantIDFromContext(ctx), Content: content, }) } diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go index fe06ddf7..6bb27243 100644 --- a/internal/tools/workspace_dir.go +++ b/internal/tools/workspace_dir.go @@ -16,11 +16,12 @@ const ( ) // WorkspaceDir returns the disk directory for a team workspace scope. -// - chatID="" → team root: {dataDir}/teams/{teamID}/ (shared mode) -// - chatID="x" → per-chat: {dataDir}/teams/{teamID}/{chatID}/ (isolated mode) +// - chatID="" → team root: {baseDir}/teams/{teamID}/ (shared mode) +// - chatID="x" → per-chat: {baseDir}/teams/{teamID}/{chatID}/ (isolated mode) +// baseDir should already be tenant-scoped (via config.TenantDataDir for non-master tenants). // Creates directory with 0750 if not exists. -func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) { - dir := filepath.Join(dataDir, "teams", teamID.String()) +func WorkspaceDir(baseDir string, teamID uuid.UUID, chatID string) (string, error) { + dir := filepath.Join(baseDir, "teams", teamID.String()) if chatID != "" { dir = filepath.Join(dir, chatID) } diff --git a/internal/tools/workspace_interceptor.go b/internal/tools/workspace_interceptor.go index 44a3ca9b..c0ca08bd 100644 --- a/internal/tools/workspace_interceptor.go +++ b/internal/tools/workspace_interceptor.go @@ -128,7 +128,7 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti } // Broadcast workspace file change event for real-time UI updates. - w.teamMgr.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ + w.teamMgr.broadcastTeamEvent(ctx, protocol.EventWorkspaceFileChanged, map[string]string{ "team_id": teamIDStr, "channel": "", "chat_id": chatID, @@ -151,11 +151,7 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti return } - // Compute relative path from workspace root for storage. - relPath, err := filepath.Rel(filepath.Clean(teamWs), filepath.Clean(path)) - if err != nil { - relPath = fileName - } + absPath := filepath.Clean(path) switch action { case "write": @@ -173,24 +169,79 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti TaskID: taskID, TeamID: teamID, ChatID: chatID, - Path: relPath, + Path: absPath, FileSize: fileSize, MimeType: mimeType, CreatedByAgentID: &agentID, } if err := w.teamMgr.teamStore.AttachFileToTask(ctx, att); err != nil { - slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", relPath, "error", err) + slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", absPath, "error", err) } else { - slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", relPath) + slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", absPath) } case "delete": - if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, relPath); err != nil { - slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", relPath, "error", err) + if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, absPath); err != nil { + slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", absPath, "error", err) } } } +// AutoAttachWorkspaceFile attaches a file inside the team workspace to the +// current task. No-op when the file is outside workspace or no task context exists. +// Safe to call redundantly — DB uses ON CONFLICT DO NOTHING for dedup. +func AutoAttachWorkspaceFile(ctx context.Context, teamStore store.TeamStore, workspace, absPath string) { + if teamStore == nil || workspace == "" { + return + } + cleanWs := filepath.Clean(workspace) + cleanPath := filepath.Clean(absPath) + if !strings.HasPrefix(cleanPath, cleanWs) { + return + } + taskIDStr := TeamTaskIDFromCtx(ctx) + teamIDStr := ToolTeamIDFromCtx(ctx) + if taskIDStr == "" || teamIDStr == "" { + return + } + taskID, err := uuid.Parse(taskIDStr) + if err != nil { + return + } + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + return + } + + var fileSize int64 + if info, err := os.Stat(cleanPath); err == nil { + fileSize = info.Size() + } + + chatID := ToolChatIDFromCtx(ctx) + if chatID == "" { + chatID = store.UserIDFromContext(ctx) + } + agentID := store.AgentIDFromContext(ctx) + + att := &store.TeamTaskAttachmentData{ + TaskID: taskID, + TeamID: teamID, + ChatID: chatID, + Path: cleanPath, + FileSize: fileSize, + MimeType: mimeFromExt(filepath.Ext(cleanPath)), + } + if agentID != uuid.Nil { + att.CreatedByAgentID = &agentID + } + if err := teamStore.AttachFileToTask(ctx, att); err != nil { + slog.Warn("workspace: auto-attach media failed", "task_id", taskIDStr, "path", cleanPath, "error", err) + } else { + slog.Debug("workspace: auto-attached media to task", "task_id", taskIDStr, "path", cleanPath) + } +} + // mimeFromExt returns a MIME type for a file extension using the stdlib registry. func mimeFromExt(ext string) string { if t := mime.TypeByExtension(ext); t != "" { diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 14f18775..80e0c495 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 25 +const RequiredSchemaVersion uint = 27 diff --git a/migrations/000026_api_key_user_binding.down.sql b/migrations/000026_api_key_user_binding.down.sql new file mode 100644 index 00000000..4df19e39 --- /dev/null +++ b/migrations/000026_api_key_user_binding.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS team_user_grants; +DROP INDEX IF EXISTS idx_api_keys_owner_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS owner_id; +-- Note: handoff_routes and delegation_history are not recreated (dead code removed) diff --git a/migrations/000026_api_key_user_binding.up.sql b/migrations/000026_api_key_user_binding.up.sql new file mode 100644 index 00000000..25a91f19 --- /dev/null +++ b/migrations/000026_api_key_user_binding.up.sql @@ -0,0 +1,21 @@ +-- API key owner binding for identity enforcement +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id VARCHAR(255); +COMMENT ON COLUMN api_keys.owner_id IS 'User who owns this key. When set, auth via this key forces user_id = owner_id.'; +CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL; + +-- Team user grants for access control +CREATE TABLE IF NOT EXISTS team_user_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + granted_by VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(team_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_team_user_grants_user ON team_user_grants(user_id); +CREATE INDEX IF NOT EXISTS idx_team_user_grants_team ON team_user_grants(team_id); + +-- Drop unused legacy tables +DROP TABLE IF EXISTS handoff_routes; +DROP TABLE IF EXISTS delegation_history; diff --git a/migrations/000027_tenant_foundation.down.sql b/migrations/000027_tenant_foundation.down.sql new file mode 100644 index 00000000..000c4130 --- /dev/null +++ b/migrations/000027_tenant_foundation.down.sql @@ -0,0 +1,112 @@ +-- Rollback: Plan 2 Tenant Foundation + +-- Phase I rollback: Restore original UNIQUE constraints +DROP INDEX IF EXISTS idx_agents_tenant_agent_key_active; +CREATE UNIQUE INDEX idx_agents_agent_key_active ON agents(agent_key) WHERE deleted_at IS NULL; + +DROP INDEX IF EXISTS idx_sessions_tenant_session_key; +ALTER TABLE sessions ADD CONSTRAINT sessions_session_key_key UNIQUE (session_key); + +DROP INDEX IF EXISTS idx_skills_tenant_slug; +ALTER TABLE skills ADD CONSTRAINT skills_slug_key UNIQUE (slug); + +DROP INDEX IF EXISTS idx_mcp_servers_tenant_name; +ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_name_key UNIQUE (name); + +DROP INDEX IF EXISTS idx_channel_contacts_tenant_type_sender; +ALTER TABLE channel_contacts ADD CONSTRAINT channel_contacts_channel_type_sender_id_key UNIQUE (channel_type, sender_id); + +-- Restore llm_providers global UNIQUE(name) +DROP INDEX IF EXISTS idx_llm_providers_tenant_name; +ALTER TABLE llm_providers ADD CONSTRAINT llm_providers_name_key UNIQUE (name); + +-- Restore config_secrets PK(key) +ALTER TABLE config_secrets DROP CONSTRAINT IF EXISTS config_secrets_pkey; +ALTER TABLE config_secrets ADD PRIMARY KEY (key); + +-- Restore paired_devices global UNIQUE(sender_id, channel) +DROP INDEX IF EXISTS idx_paired_devices_tenant_sender_channel; +ALTER TABLE paired_devices ADD CONSTRAINT paired_devices_sender_id_channel_key UNIQUE (sender_id, channel); + +-- Restore channel_instances global UNIQUE(name) +DROP INDEX IF EXISTS idx_channel_instances_tenant_name; +ALTER TABLE channel_instances ADD CONSTRAINT channel_instances_name_key UNIQUE (name); + +-- Restore original usage_snapshots unique index (without tenant_id) +DROP INDEX IF EXISTS idx_usage_snapshots_unique; +CREATE UNIQUE INDEX idx_usage_snapshots_unique ON usage_snapshots ( + bucket_hour, + COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), + provider, model, channel +); + +-- Drop new tables +DROP TABLE IF EXISTS mcp_user_credentials; +DROP TABLE IF EXISTS skill_tenant_configs; +DROP TABLE IF EXISTS builtin_tool_tenant_configs; +DROP TABLE IF EXISTS tenant_users; + +-- Drop tenant_id from all tables (reverse order) +ALTER TABLE team_task_attachments DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_task_events DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_task_comments DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_team_members DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE embedding_cache DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE spans DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_tasks DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_agent_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE skill_agent_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_context_files DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE secure_cli_binaries DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE config_secrets DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE llm_providers DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE channel_contacts DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE channel_pending_messages DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE paired_devices DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE pairing_requests DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_user_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_teams DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_access_requests DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_user_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_servers DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE usage_snapshots DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE activity_logs DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE traces DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE cron_jobs DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE skill_user_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE skills DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE kg_relations DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE kg_entities DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE memory_chunks DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE memory_documents DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE channel_instances DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_links DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_config_permissions DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_agent_profiles DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_context_files DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_shares DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE sessions DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agents DROP COLUMN IF EXISTS tenant_id; + +-- Recreate custom_tools table (restore from 000001 schema) +CREATE TABLE IF NOT EXISTS custom_tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + parameters JSONB DEFAULT '{}', + command TEXT NOT NULL, + working_dir TEXT DEFAULT '', + timeout_seconds INT DEFAULT 30, + env BYTEA, + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + enabled BOOLEAN DEFAULT true, + created_by VARCHAR(255), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Drop tenants table last (FKs already removed) +DROP TABLE IF EXISTS tenants; diff --git a/migrations/000027_tenant_foundation.up.sql b/migrations/000027_tenant_foundation.up.sql new file mode 100644 index 00000000..995f6b02 --- /dev/null +++ b/migrations/000027_tenant_foundation.up.sql @@ -0,0 +1,358 @@ +-- Plan 2: Tenant Foundation +-- Master tenant UUID v7: 0193a5b0-7000-7000-8000-000000000001 + +-- ============================================================ +-- Phase A: Create tenants + tenant_users tables +-- ============================================================ + +CREATE TABLE tenants ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status) WHERE status = 'active'; + +-- Seed master tenant +INSERT INTO tenants (id, name, slug, status) +VALUES ('0193a5b0-7000-7000-8000-000000000001', 'Master', 'master', 'active'); + +CREATE TABLE tenant_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + role VARCHAR(20) NOT NULL DEFAULT 'member', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, user_id) +); + +CREATE INDEX idx_tenant_users_user ON tenant_users(user_id); +CREATE INDEX idx_tenant_users_tenant ON tenant_users(tenant_id); + +-- ============================================================ +-- Phase B: ALTER ADD tenant_id to 30 tables +-- All default to master tenant UUID (PG 11+ metadata-only op) +-- ============================================================ + +-- Core tables +ALTER TABLE agents ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE sessions ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +-- api_keys: NULLABLE (NULL = system-level cross-tenant key) +ALTER TABLE api_keys ADD COLUMN tenant_id UUID REFERENCES tenants(id); + +-- Agent ecosystem +ALTER TABLE agent_shares ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE user_context_files ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE user_agent_profiles ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE user_agent_overrides ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE agent_config_permissions ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE agent_links ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE channel_instances ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Memory + KG +ALTER TABLE memory_documents ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE memory_chunks ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE kg_entities ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE kg_relations ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Skills +ALTER TABLE skills ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE skill_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Cron +ALTER TABLE cron_jobs ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Tracing + Activity +ALTER TABLE traces ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE activity_logs ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE usage_snapshots ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- MCP +ALTER TABLE mcp_servers ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE mcp_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE mcp_access_requests ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Teams +ALTER TABLE agent_teams ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Pairing + Channels +ALTER TABLE pairing_requests ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE paired_devices ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE channel_pending_messages ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE channel_contacts ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- LLM Providers + Config Secrets +ALTER TABLE llm_providers ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE config_secrets ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Other +ALTER TABLE secure_cli_binaries ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Grant tables +ALTER TABLE agent_context_files ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE skill_agent_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE mcp_agent_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Tasks + Tracing +ALTER TABLE team_tasks ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE spans ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Cache +ALTER TABLE embedding_cache ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Team activity tables +ALTER TABLE agent_team_members ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_task_comments ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_task_events ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_task_attachments ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- ============================================================ +-- Phase C: Drop defaults (force explicit tenant_id for new rows) +-- ============================================================ + +ALTER TABLE agents ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE sessions ALTER COLUMN tenant_id DROP DEFAULT; +-- api_keys: no default to drop (nullable, no DEFAULT set) +ALTER TABLE agent_shares ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE user_context_files ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE user_agent_profiles ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE user_agent_overrides ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_config_permissions ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_links ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE channel_instances ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE memory_documents ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE memory_chunks ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE kg_entities ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE kg_relations ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE skills ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE skill_user_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE cron_jobs ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE traces ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE activity_logs ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE usage_snapshots ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_servers ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_user_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_access_requests ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_teams ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_user_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE pairing_requests ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE paired_devices ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE channel_pending_messages ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE channel_contacts ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE llm_providers ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE config_secrets ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE secure_cli_binaries ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_context_files ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE skill_agent_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_agent_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_tasks ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE spans ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE embedding_cache ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_team_members ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_task_comments ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_task_events ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_task_attachments ALTER COLUMN tenant_id DROP DEFAULT; + +-- ============================================================ +-- Phase D: Indexes +-- ============================================================ + +-- Per-table tenant indexes +CREATE INDEX idx_agents_tenant ON agents(tenant_id); +CREATE INDEX idx_sessions_tenant ON sessions(tenant_id); +CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE tenant_id IS NOT NULL; +CREATE INDEX idx_agent_shares_tenant ON agent_shares(tenant_id); +CREATE INDEX idx_user_context_files_tenant ON user_context_files(tenant_id); +CREATE INDEX idx_user_agent_profiles_tenant ON user_agent_profiles(tenant_id); +CREATE INDEX idx_user_agent_overrides_tenant ON user_agent_overrides(tenant_id); +CREATE INDEX idx_agent_config_permissions_tenant ON agent_config_permissions(tenant_id); +CREATE INDEX idx_agent_links_tenant ON agent_links(tenant_id); +CREATE INDEX idx_channel_instances_tenant ON channel_instances(tenant_id); +CREATE INDEX idx_memory_documents_tenant ON memory_documents(tenant_id); +CREATE INDEX idx_memory_chunks_tenant ON memory_chunks(tenant_id); +CREATE INDEX idx_kg_entities_tenant ON kg_entities(tenant_id); +CREATE INDEX idx_kg_relations_tenant ON kg_relations(tenant_id); +CREATE INDEX idx_skills_tenant ON skills(tenant_id); +CREATE INDEX idx_skill_user_grants_tenant ON skill_user_grants(tenant_id); +CREATE INDEX idx_cron_jobs_tenant ON cron_jobs(tenant_id); +CREATE INDEX idx_traces_tenant ON traces(tenant_id); +CREATE INDEX idx_activity_logs_tenant ON activity_logs(tenant_id); +CREATE INDEX idx_usage_snapshots_tenant ON usage_snapshots(tenant_id); +CREATE INDEX idx_mcp_servers_tenant ON mcp_servers(tenant_id); +CREATE INDEX idx_mcp_user_grants_tenant ON mcp_user_grants(tenant_id); +CREATE INDEX idx_mcp_access_requests_tenant ON mcp_access_requests(tenant_id); +CREATE INDEX idx_agent_teams_tenant ON agent_teams(tenant_id); +CREATE INDEX idx_team_user_grants_tenant ON team_user_grants(tenant_id); +CREATE INDEX idx_pairing_requests_tenant ON pairing_requests(tenant_id); +CREATE INDEX idx_paired_devices_tenant ON paired_devices(tenant_id); +CREATE INDEX idx_channel_pending_messages_tenant ON channel_pending_messages(tenant_id); +CREATE INDEX idx_channel_contacts_tenant ON channel_contacts(tenant_id); +CREATE INDEX idx_llm_providers_tenant ON llm_providers(tenant_id); +CREATE INDEX idx_config_secrets_tenant ON config_secrets(tenant_id); +CREATE INDEX idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id); +CREATE INDEX idx_agent_context_files_tenant ON agent_context_files(tenant_id); +CREATE INDEX idx_skill_agent_grants_tenant ON skill_agent_grants(tenant_id); +CREATE INDEX idx_mcp_agent_grants_tenant ON mcp_agent_grants(tenant_id); +CREATE INDEX idx_team_tasks_tenant ON team_tasks(tenant_id); +CREATE INDEX idx_spans_tenant ON spans(tenant_id); +CREATE INDEX idx_embedding_cache_tenant ON embedding_cache(tenant_id); +CREATE INDEX idx_agent_team_members_tenant ON agent_team_members(tenant_id); +CREATE INDEX idx_team_task_comments_tenant ON team_task_comments(tenant_id); +CREATE INDEX idx_team_task_events_tenant ON team_task_events(tenant_id); +CREATE INDEX idx_team_task_attachments_tenant ON team_task_attachments(tenant_id); + +-- Composite indexes for Plan 3 query performance +CREATE INDEX idx_agents_tenant_active ON agents(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_sessions_tenant_user ON sessions(tenant_id, user_id); +CREATE INDEX idx_traces_tenant_time ON traces(tenant_id, created_at DESC); + +-- ============================================================ +-- Phase E: Seed master tenant owner from existing agents +-- ============================================================ + +INSERT INTO tenant_users (tenant_id, user_id, role) +SELECT DISTINCT '0193a5b0-7000-7000-8000-000000000001'::uuid, owner_id, 'owner' +FROM agents +WHERE owner_id IS NOT NULL AND owner_id != '' +LIMIT 1 +ON CONFLICT (tenant_id, user_id) DO NOTHING; + +-- ============================================================ +-- Phase F: DROP custom_tools table (dead code — agent loop never wired) +-- ============================================================ + +DROP TABLE IF EXISTS custom_tools; + +-- ============================================================ +-- Phase G: Per-tenant builtin tool config overrides +-- ============================================================ + +CREATE TABLE builtin_tool_tenant_configs ( + tool_name VARCHAR(100) NOT NULL REFERENCES builtin_tools(name) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN, + settings JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tool_name, tenant_id) +); + +CREATE INDEX idx_builtin_tool_tenant_configs_tenant ON builtin_tool_tenant_configs(tenant_id); + +-- ============================================================ +-- Phase H: Per-tenant skill config (disable system skills) +-- ============================================================ + +CREATE TABLE skill_tenant_configs ( + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (skill_id, tenant_id) +); + +CREATE INDEX idx_skill_tenant_configs_tenant ON skill_tenant_configs(tenant_id); + +-- ============================================================ +-- Phase J: MCP per-user credentials +-- ============================================================ + +CREATE TABLE mcp_user_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + api_key TEXT, + headers BYTEA, + env BYTEA, + tenant_id UUID NOT NULL REFERENCES tenants(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(server_id, user_id, tenant_id) +); + +CREATE INDEX idx_mcp_user_credentials_tenant ON mcp_user_credentials(tenant_id); +CREATE INDEX idx_mcp_user_credentials_server ON mcp_user_credentials(server_id); + +-- ============================================================ +-- Phase I: Update UNIQUE constraints to include tenant_id +-- Allows same name/key/slug across different tenants. +-- ============================================================ + +-- agents.agent_key: (agent_key) → (tenant_id, agent_key) +-- Old constraint replaced by partial index in migration 23. +DROP INDEX IF EXISTS idx_agents_agent_key_active; +CREATE UNIQUE INDEX idx_agents_tenant_agent_key_active ON agents(tenant_id, agent_key) WHERE deleted_at IS NULL; + +-- sessions.session_key: globally unique → (tenant_id, session_key) +ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_session_key_key; +DROP INDEX IF EXISTS sessions_session_key_key; +CREATE UNIQUE INDEX idx_sessions_tenant_session_key ON sessions(tenant_id, session_key); + +-- skills.slug: globally unique → (tenant_id, slug) +ALTER TABLE skills DROP CONSTRAINT IF EXISTS skills_slug_key; +DROP INDEX IF EXISTS skills_slug_key; +CREATE UNIQUE INDEX idx_skills_tenant_slug ON skills(tenant_id, slug); + +-- mcp_servers.name: globally unique → (tenant_id, name) +ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_name_key; +DROP INDEX IF EXISTS mcp_servers_name_key; +CREATE UNIQUE INDEX idx_mcp_servers_tenant_name ON mcp_servers(tenant_id, name); + +-- channel_contacts: (channel_type, sender_id) → (tenant_id, channel_type, sender_id) +ALTER TABLE channel_contacts DROP CONSTRAINT IF EXISTS channel_contacts_channel_type_sender_id_key; +DROP INDEX IF EXISTS channel_contacts_channel_type_sender_id_key; +CREATE UNIQUE INDEX idx_channel_contacts_tenant_type_sender ON channel_contacts(tenant_id, channel_type, sender_id); + +-- llm_providers.name: globally unique → (tenant_id, name) +ALTER TABLE llm_providers DROP CONSTRAINT IF EXISTS llm_providers_name_key; +DROP INDEX IF EXISTS llm_providers_name_key; +CREATE UNIQUE INDEX idx_llm_providers_tenant_name ON llm_providers(tenant_id, name); + +-- config_secrets.key: PK (key) → PK (key, tenant_id) +ALTER TABLE config_secrets DROP CONSTRAINT IF EXISTS config_secrets_pkey; +ALTER TABLE config_secrets ADD PRIMARY KEY (key, tenant_id); + +-- paired_devices: UNIQUE (sender_id, channel) → (tenant_id, sender_id, channel) +ALTER TABLE paired_devices DROP CONSTRAINT IF EXISTS paired_devices_sender_id_channel_key; +DROP INDEX IF EXISTS paired_devices_sender_id_channel_key; +CREATE UNIQUE INDEX idx_paired_devices_tenant_sender_channel ON paired_devices(tenant_id, sender_id, channel); + +-- channel_instances.name: globally unique → (tenant_id, name) +ALTER TABLE channel_instances DROP CONSTRAINT IF EXISTS channel_instances_name_key; +DROP INDEX IF EXISTS channel_instances_name_key; +CREATE UNIQUE INDEX idx_channel_instances_tenant_name ON channel_instances(tenant_id, name); + +-- usage_snapshots: add tenant_id to unique conflict index for per-tenant aggregation +DROP INDEX IF EXISTS idx_usage_snapshots_unique; +CREATE UNIQUE INDEX idx_usage_snapshots_unique ON usage_snapshots ( + bucket_hour, + COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), + provider, model, channel, + tenant_id +); + +-- Cleanup: strip leaked gateway tokens from session media URLs. +-- Old code embedded ?token=GATEWAY_TOKEN in markdown image URLs stored in session messages. +-- New code stores clean paths; frontend adds auth at render time. +UPDATE sessions +SET messages = regexp_replace(messages::text, '\?token=[a-f0-9]+', '', 'g')::jsonb +WHERE messages::text LIKE '%?token=%'; + +-- ============================================================ +-- Phase K: Migrate remaining UUID v4 defaults to v7 +-- ============================================================ + +ALTER TABLE kg_entities ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE kg_relations ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE channel_contacts ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE team_user_grants ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE tenant_users ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE mcp_user_credentials ALTER COLUMN id SET DEFAULT uuid_generate_v7(); diff --git a/pkg/browser/actions.go b/pkg/browser/actions.go index 83c27c5b..fffe6996 100644 --- a/pkg/browser/actions.go +++ b/pkg/browser/actions.go @@ -12,7 +12,7 @@ import ( // Click clicks an element by ref. func (m *Manager) Click(ctx context.Context, targetID, ref string, opts ClickOpts) error { - _, el, err := m.getPageAndResolve(targetID, ref) + _, el, err := m.getPageAndResolve(ctx, targetID, ref) if err != nil { return err } @@ -34,7 +34,7 @@ func (m *Manager) Click(ctx context.Context, targetID, ref string, opts ClickOpt // Type types text into an element by ref. func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts TypeOpts) error { - page, el, err := m.getPageAndResolve(targetID, ref) + page, el, err := m.getPageAndResolve(ctx, targetID, ref) if err != nil { return err } @@ -63,8 +63,9 @@ func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts Typ // Press presses a keyboard key. func (m *Manager) Press(ctx context.Context, targetID, key string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return err @@ -76,7 +77,7 @@ func (m *Manager) Press(ctx context.Context, targetID, key string) error { // Hover hovers over an element by ref. func (m *Manager) Hover(ctx context.Context, targetID, ref string) error { - _, el, err := m.getPageAndResolve(targetID, ref) + _, el, err := m.getPageAndResolve(ctx, targetID, ref) if err != nil { return err } @@ -86,8 +87,9 @@ func (m *Manager) Hover(ctx context.Context, targetID, ref string) error { // Wait waits for a condition on a page. func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return err @@ -148,8 +150,9 @@ func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) erro // Evaluate runs JavaScript on a page. func (m *Manager) Evaluate(ctx context.Context, targetID, js string) (string, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return "", err diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index bedcdaac..338c4ec4 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -12,14 +12,16 @@ import ( // Manager handles the Chrome browser lifecycle and page management. type Manager struct { - mu sync.Mutex - browser *rod.Browser - refs *RefStore - pages map[string]*rod.Page // targetID → page - console map[string][]ConsoleMessage // targetID → console messages - headless bool - remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher - logger *slog.Logger + mu sync.Mutex + browser *rod.Browser + refs *RefStore + pages map[string]*rod.Page // targetID → page + console map[string][]ConsoleMessage // targetID → console messages + tenantCtxs map[string]*rod.Browser // tenantID → incognito browser context + pageTenants map[string]string // targetID → tenantID (for filtering) + headless bool + remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher + logger *slog.Logger } // Option configures a Manager. @@ -44,10 +46,12 @@ func WithLogger(l *slog.Logger) Option { // New creates a Manager with options. func New(opts ...Option) *Manager { m := &Manager{ - refs: NewRefStore(), - pages: make(map[string]*rod.Page), - console: make(map[string][]ConsoleMessage), - logger: slog.Default(), + refs: NewRefStore(), + pages: make(map[string]*rod.Page), + console: make(map[string][]ConsoleMessage), + tenantCtxs: make(map[string]*rod.Browser), + pageTenants: make(map[string]string), + logger: slog.Default(), } for _, o := range opts { o(m) @@ -68,9 +72,11 @@ func (m *Manager) Start(ctx context.Context) error { } // Connection dead — clean up and reconnect m.logger.Info("browser connection lost, reconnecting") + m.closeTenantContextsLocked() m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) m.refs = NewRefStore() } @@ -118,6 +124,8 @@ func (m *Manager) Stop(ctx context.Context) error { return nil } + m.closeTenantContextsLocked() + var err error if m.remoteURL == "" { // Local Chrome — close the browser process @@ -128,9 +136,49 @@ func (m *Manager) Stop(ctx context.Context) error { m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) return err } +// closeTenantContextsLocked closes all incognito browser contexts. Must be called with mu held. +func (m *Manager) closeTenantContextsLocked() { + for tid, ctx := range m.tenantCtxs { + if err := ctx.Close(); err != nil { + m.logger.Warn("failed to close tenant browser context", "tenant", tid, "error", err) + } + } + m.tenantCtxs = make(map[string]*rod.Browser) +} + +// MasterTenantID is the well-known master tenant UUID string. +// Pages opened without a tenant context or by the master tenant use the main browser directly. +const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001" + +// tenantBrowserLocked returns an isolated incognito browser context for the given tenant. +// Master tenant and empty string use the main browser (no isolation needed). +// Must be called with mu held. +func (m *Manager) tenantBrowserLocked(tenantID string) (*rod.Browser, error) { + if m.browser == nil { + return nil, fmt.Errorf("browser not running") + } + // Master tenant or no tenant: use main browser + if tenantID == "" || tenantID == MasterTenantID { + return m.browser, nil + } + // Return existing incognito context + if ctx, ok := m.tenantCtxs[tenantID]; ok { + return ctx, nil + } + // Create new incognito context for this tenant + incognito, err := m.browser.Incognito() + if err != nil { + return nil, fmt.Errorf("create incognito context for tenant %s: %w", tenantID, err) + } + m.tenantCtxs[tenantID] = incognito + m.logger.Info("created incognito browser context", "tenant", tenantID) + return incognito, nil +} + // Status returns current browser status. func (m *Manager) Status() *StatusInfo { m.mu.Lock() diff --git a/pkg/browser/browser_page.go b/pkg/browser/browser_page.go index 2f5f429f..20cc56a4 100644 --- a/pkg/browser/browser_page.go +++ b/pkg/browser/browser_page.go @@ -10,8 +10,9 @@ import ( // Snapshot takes an accessibility snapshot of a page. func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOptions) (*SnapshotResult, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { @@ -39,8 +40,9 @@ func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOp // Screenshot captures a page screenshot as PNG bytes. func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool) ([]byte, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { @@ -57,8 +59,9 @@ func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool // Navigate navigates a page to a URL. func (m *Manager) Navigate(ctx context.Context, targetID, url string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { diff --git a/pkg/browser/browser_remote.go b/pkg/browser/browser_remote.go index c636f3af..a2a339f9 100644 --- a/pkg/browser/browser_remote.go +++ b/pkg/browser/browser_remote.go @@ -1,6 +1,7 @@ package browser import ( + "context" "encoding/json" "fmt" "net" @@ -16,9 +17,11 @@ import ( // reconnectLocked re-establishes the CDP connection to a remote Chrome. // Must be called with m.mu held. Only works when remoteURL is set. func (m *Manager) reconnectLocked() error { + m.closeTenantContextsLocked() m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) m.refs = NewRefStore() controlURL, err := resolveRemoteCDP(m.remoteURL) @@ -87,6 +90,29 @@ func (m *Manager) getPage(targetID string) (*rod.Page, error) { return pages[0], nil } +// getPageForTenant wraps getPage with tenant ownership validation. +// If tenantID is set and the page belongs to a different tenant, access is denied. +// Must be called with m.mu held. +func (m *Manager) getPageForTenant(targetID, tenantID string) (*rod.Page, error) { + page, err := m.getPage(targetID) + if err != nil { + return nil, err + } + // If no tenant context or master tenant, allow access to all pages + if tenantID == "" || tenantID == MasterTenantID { + return page, nil + } + // Check ownership: page must belong to this tenant + resolvedTID := targetID + if targetID == "" { + resolvedTID = string(page.TargetID) + } + if owner, ok := m.pageTenants[resolvedTID]; ok && owner != tenantID { + return nil, fmt.Errorf("tab not found: %s", targetID) + } + return page, nil +} + // setupConsoleListener attaches a console message listener to a page via Rod's EachEvent. func (m *Manager) setupConsoleListener(page *rod.Page, targetID string) { go page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) { @@ -146,10 +172,11 @@ func (m *Manager) resolveElement(page *rod.Page, targetID, ref string) (*rod.Ele return el, nil } -// getPageAndResolve is a helper that locks, gets page, and resolves an element. -func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Element, error) { +// getPageAndResolve is a helper that locks, gets page with tenant check, and resolves an element. +func (m *Manager) getPageAndResolve(ctx context.Context, targetID, ref string) (*rod.Page, *rod.Element, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return nil, nil, err diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go index bc067fc2..08c138e1 100644 --- a/pkg/browser/browser_tabs.go +++ b/pkg/browser/browser_tabs.go @@ -8,7 +8,7 @@ import ( "github.com/go-rod/rod/lib/proto" ) -// ListTabs returns all open tabs. +// ListTabs returns open tabs filtered by the caller's tenant context. func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -17,14 +17,27 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { return nil, fmt.Errorf("browser not running") } - pages, err := m.browser.Pages() + tenantID := tenantIDFromCtx(ctx) + + // Use tenant-scoped browser context for page listing + b, err := m.tenantBrowserLocked(tenantID) + if err != nil { + return nil, err + } + + pages, err := b.Pages() if err != nil { if m.remoteURL != "" { if reconnErr := m.reconnectLocked(); reconnErr != nil { return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) } m.logger.Info("auto-reconnected to remote Chrome") - pages, err = m.browser.Pages() + // Re-acquire tenant browser after reconnect (incognito contexts were reset) + b, err = m.tenantBrowserLocked(tenantID) + if err != nil { + return nil, err + } + pages, err = b.Pages() if err != nil { return nil, fmt.Errorf("list pages after reconnect: %w", err) } @@ -41,6 +54,9 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { } tid := string(p.TargetID) m.pages[tid] = p + if tenantID != "" { + m.pageTenants[tid] = tenantID + } tabs = append(tabs, TabInfo{ TargetID: tid, URL: info.URL, @@ -51,15 +67,18 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { } // OpenTab opens a new tab with the given URL. +// Pages are created within the tenant's incognito browser context for isolation. func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { m.mu.Lock() defer m.mu.Unlock() - if m.browser == nil { - return nil, fmt.Errorf("browser not running") + tenantID := tenantIDFromCtx(ctx) + b, err := m.tenantBrowserLocked(tenantID) + if err != nil { + return nil, err } - page, err := m.browser.Page(proto.TargetCreateTarget{URL: url}) + page, err := b.Page(proto.TargetCreateTarget{URL: url}) if err != nil { return nil, fmt.Errorf("open tab: %w", err) } @@ -70,6 +89,9 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { info, _ := page.Info() tid := string(page.TargetID) m.pages[tid] = page + if tenantID != "" { + m.pageTenants[tid] = tenantID + } // Set up console listener m.setupConsoleListener(page, tid) @@ -84,10 +106,11 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { // FocusTab activates a tab. func (m *Manager) FocusTab(ctx context.Context, targetID string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() defer m.mu.Unlock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) if err != nil { return err } @@ -98,24 +121,34 @@ func (m *Manager) FocusTab(ctx context.Context, targetID string) error { // CloseTab closes a tab. func (m *Manager) CloseTab(ctx context.Context, targetID string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() defer m.mu.Unlock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) if err != nil { return err } delete(m.pages, targetID) delete(m.console, targetID) + delete(m.pageTenants, targetID) return page.Close() } // ConsoleMessages returns captured console messages for a tab. -func (m *Manager) ConsoleMessages(targetID string) []ConsoleMessage { +func (m *Manager) ConsoleMessages(ctx context.Context, targetID string) []ConsoleMessage { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() defer m.mu.Unlock() + // Validate tenant ownership + if tenantID != "" && tenantID != MasterTenantID { + if owner, ok := m.pageTenants[targetID]; ok && owner != tenantID { + return []ConsoleMessage{} + } + } + msgs := m.console[targetID] if msgs == nil { return []ConsoleMessage{} diff --git a/pkg/browser/browser_tenant.go b/pkg/browser/browser_tenant.go new file mode 100644 index 00000000..cc7a73bc --- /dev/null +++ b/pkg/browser/browser_tenant.go @@ -0,0 +1,20 @@ +package browser + +import "context" + +// browserTenantKey is a context key for passing tenant ID to browser operations. +type browserTenantKey struct{} + +// WithTenantID returns a context with the browser tenant ID set. +// This is used to isolate browser pages per tenant via incognito contexts. +func WithTenantID(ctx context.Context, tenantID string) context.Context { + return context.WithValue(ctx, browserTenantKey{}, tenantID) +} + +// tenantIDFromCtx extracts the tenant ID from context. +func tenantIDFromCtx(ctx context.Context) string { + if v, ok := ctx.Value(browserTenantKey{}).(string); ok { + return v + } + return "" +} diff --git a/pkg/browser/tool.go b/pkg/browser/tool.go index c26ee125..08b6efba 100644 --- a/pkg/browser/tool.go +++ b/pkg/browser/tool.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) @@ -137,6 +138,11 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R return tools.ErrorResult("action is required") } + // Propagate tenant ID from store context to browser context for page isolation. + if tid := store.TenantIDFromContext(ctx); tid.String() != "00000000-0000-0000-0000-000000000000" { + ctx = WithTenantID(ctx, tid.String()) + } + // Auto-start browser for actions that need it switch action { case "open", "snapshot", "screenshot", "navigate", "act", "tabs": @@ -165,7 +171,7 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R case "navigate": return t.handleNavigate(ctx, args) case "console": - return t.handleConsole(args) + return t.handleConsole(ctx, args) case "act": return t.handleAct(ctx, args) default: @@ -279,9 +285,9 @@ func (t *BrowserTool) handleNavigate(ctx context.Context, args map[string]any) * return tools.NewResult(fmt.Sprintf("Navigated to %s", url)) } -func (t *BrowserTool) handleConsole(args map[string]any) *tools.Result { +func (t *BrowserTool) handleConsole(ctx context.Context, args map[string]any) *tools.Result { targetID, _ := args["targetId"].(string) - msgs := t.manager.ConsoleMessages(targetID) + msgs := t.manager.ConsoleMessages(ctx, targetID) return jsonResult(msgs) } diff --git a/pkg/protocol/errors.go b/pkg/protocol/errors.go index bdff63ed..bfe6e4fa 100644 --- a/pkg/protocol/errors.go +++ b/pkg/protocol/errors.go @@ -13,6 +13,7 @@ const ( ErrNotFound = "NOT_FOUND" ErrAlreadyExists = "ALREADY_EXISTS" ErrResourceExhausted = "RESOURCE_EXHAUSTED" - ErrFailedPrecondition = "FAILED_PRECONDITION" - ErrInternal = "INTERNAL" + ErrFailedPrecondition = "FAILED_PRECONDITION" + ErrInternal = "INTERNAL" + ErrTenantAccessRevoked = "TENANT_ACCESS_REVOKED" ) diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index 14ac156f..c295b278 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -97,6 +97,9 @@ const ( // Zalo Personal QR login events (client-scoped, not broadcast). EventZaloPersonalQRCode = "zalo.personal.qr.code" EventZaloPersonalQRDone = "zalo.personal.qr.done" + + // Tenant access revocation — forces affected user's UI to logout. + EventTenantAccessRevoked = "tenant.access.revoked" ) // Agent event subtypes (in payload.type) diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index a26bb693..cd1eecbe 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -156,12 +156,6 @@ const ( MethodTeamsEventsList = "teams.events.list" ) -// Delegation history -const ( - MethodDelegationsList = "delegations.list" - MethodDelegationsGet = "delegations.get" -) - // API key management const ( MethodAPIKeysList = "api_keys.list" diff --git a/ui/web/index.html b/ui/web/index.html index 8a30c15f..4c4c0245 100644 --- a/ui/web/index.html +++ b/ui/web/index.html @@ -4,6 +4,9 @@ + + + GoClaw Dashboard diff --git a/ui/web/public/favicon.svg b/ui/web/public/favicon.svg new file mode 100644 index 00000000..5a23afe8 --- /dev/null +++ b/ui/web/public/favicon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/web/public/goclaw-icon.svg b/ui/web/public/goclaw-icon.svg new file mode 100644 index 00000000..797941f5 --- /dev/null +++ b/ui/web/public/goclaw-icon.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index a6018859..2b73dc7b 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -111,6 +111,9 @@ export class HttpClient { if (userId) h["X-GoClaw-User-Id"] = userId; const senderID = this.getSenderID(); if (senderID) h["X-GoClaw-Sender-Id"] = senderID; + // Tenant scope: narrow cross-tenant admin to a specific tenant + const tenantScope = localStorage.getItem("goclaw:tenant_id"); + if (tenantScope) h["X-GoClaw-Tenant-Id"] = tenantScope; return h; } @@ -130,10 +133,10 @@ export class HttpClient { } if (!res.ok) { - if (res.status === 401) { + const err = await res.json().catch(() => ({ error: res.statusText })); + if (res.status === 401 || err.code === "TENANT_ACCESS_REVOKED") { this.onAuthFailure?.(); } - const err = await res.json().catch(() => ({ error: res.statusText })); throw new ApiError( err.code ?? "HTTP_ERROR", err.error ?? err.message ?? res.statusText, diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index e194dc03..6f0bb046 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -167,6 +167,16 @@ export const Methods = { CONFIG_PERMISSIONS_GRANT: "config.permissions.grant", CONFIG_PERMISSIONS_REVOKE: "config.permissions.revoke", + // Tenants (multi-tenant) + TENANTS_MINE: "tenants.mine", + TENANTS_LIST: "tenants.list", + TENANTS_GET: "tenants.get", + TENANTS_CREATE: "tenants.create", + TENANTS_UPDATE: "tenants.update", + TENANTS_USERS_LIST: "tenants.users.list", + TENANTS_USERS_ADD: "tenants.users.add", + TENANTS_USERS_REMOVE: "tenants.users.remove", + // Phase 3+ - NICE TO HAVE LOGS_TAIL: "logs.tail", } as const; diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts index 07a5832b..eb289b49 100644 --- a/ui/web/src/api/ws-client.ts +++ b/ui/web/src/api/ws-client.ts @@ -23,6 +23,15 @@ export class WsClient { private intentionalClose = false; private connectGeneration = 0; + /** Server-assigned role from connect response. */ + role: "admin" | "operator" | "viewer" | "" = ""; + + /** Tenant fields from connect response. */ + tenantId = ""; + tenantName = ""; + tenantSlug = ""; + crossTenant = false; + private readonly maxReconnectDelay = 30_000; private readonly baseReconnectDelay = 1_000; private readonly defaultTimeout = 30_000; @@ -193,11 +202,17 @@ export class WsClient { status?: string; pairing_code?: string; sender_id?: string; + tenant_id?: string; + tenant_name?: string; + tenant_slug?: string; + cross_tenant?: boolean; }>("connect", { token: this.getToken(), user_id: this.getUserId(), sender_id: this.getSenderID(), locale: localStorage.getItem("goclaw:language") || "en", + tenant_hint: localStorage.getItem("goclaw:tenant_hint") || "", + tenant_id: localStorage.getItem("goclaw:tenant_id") || "", protocolVersion: PROTOCOL_VERSION, }); if (this.connectGeneration !== generation) return; @@ -218,9 +233,21 @@ export class WsClient { } this.authenticated = true; + this.role = (res?.role as "admin" | "operator" | "viewer") ?? ""; + this.tenantId = res?.tenant_id ?? ""; + this.tenantName = res?.tenant_name ?? ""; + this.tenantSlug = res?.tenant_slug ?? ""; + this.crossTenant = res?.cross_tenant ?? false; this.onStateChange("connected"); - } catch { + } catch (e) { if (this.connectGeneration === generation) { + // Tenant access revoked → force logout instead of reconnect + if (e instanceof ApiError && e.code === "TENANT_ACCESS_REVOKED") { + this.intentionalClose = true; + this.ws?.close(); + this.onAuthFailure?.(); + return; + } this.ws?.close(); } } @@ -252,7 +279,7 @@ export class WsClient { pending.resolve(frame.payload); } else { const err = frame.error as ErrorShape; - if (err.code === "UNAUTHORIZED") { + if (err.code === "UNAUTHORIZED" || err.code === "TENANT_ACCESS_REVOKED") { this.onAuthFailure?.(); } pending.reject( diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx index ace4534f..cc9e66a2 100644 --- a/ui/web/src/components/chat/active-run-zone.tsx +++ b/ui/web/src/components/chat/active-run-zone.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Bot } from "lucide-react"; import { ActivityIndicator } from "./activity-indicator"; import { BlockReplyBubble } from "./block-reply-bubble"; @@ -15,7 +16,7 @@ interface ActiveRunZoneProps { blockReplies: ChatMessage[]; } -export function ActiveRunZone({ +export const ActiveRunZone = memo(function ActiveRunZone({ isRunning, activity, thinkingText, @@ -69,4 +70,4 @@ export function ActiveRunZone({ ); -} +}); diff --git a/ui/web/src/components/chat/activity-indicator.tsx b/ui/web/src/components/chat/activity-indicator.tsx index abff3717..62aabfd9 100644 --- a/ui/web/src/components/chat/activity-indicator.tsx +++ b/ui/web/src/components/chat/activity-indicator.tsx @@ -34,7 +34,7 @@ export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProp function getPhaseConfig(activity: RunActivity) { switch (activity.phase) { case "thinking": - return { icon: Brain, color: "text-purple-500", label: "Thinking..." }; + return { icon: Brain, color: "text-orange-500", label: "Thinking..." }; case "tool_exec": return { icon: Wrench, diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index 24fe6a27..e391b4ff 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -51,7 +51,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { /* Normal message bubble — assistant uses full width, user capped at 85% */

{hasThinking && ( @@ -73,7 +73,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
)} {message.timestamp && ( -
+
{new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
)} diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx index 35cf9d10..3a70ec4e 100644 --- a/ui/web/src/components/chat/rich-content.tsx +++ b/ui/web/src/components/chat/rich-content.tsx @@ -264,7 +264,7 @@ export function RichContent({ content, role }: RichContentProps) { // If no special blocks found, render as plain markdown (fast path) const first = blocks[0]; if (blocks.length === 1 && first?.type === "markdown") { - return ; + return ; } return ( @@ -278,7 +278,7 @@ export function RichContent({ content, role }: RichContentProps) { case "video-notice": return ; case "markdown": - return ; + return ; case "file": return ; case "reply": diff --git a/ui/web/src/components/chat/streaming-text.tsx b/ui/web/src/components/chat/streaming-text.tsx index b85fef9b..45d9c6bc 100644 --- a/ui/web/src/components/chat/streaming-text.tsx +++ b/ui/web/src/components/chat/streaming-text.tsx @@ -1,10 +1,11 @@ +import { memo } from "react"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; interface StreamingTextProps { text: string; } -export function StreamingText({ text }: StreamingTextProps) { +export const StreamingText = memo(function StreamingText({ text }: StreamingTextProps) { if (!text) return ; return ( @@ -13,7 +14,7 @@ export function StreamingText({ text }: StreamingTextProps) {
); -} +}); function ThinkingIndicator() { return ( diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index c7ae94c9..f8857409 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -26,6 +26,7 @@ import { Contact, KeyRound, FileText, + Building2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; @@ -34,6 +35,8 @@ import { ConnectionStatus } from "./connection-status"; import { ROUTES } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { useTenants } from "@/hooks/use-tenants"; interface SidebarProps { collapsed: boolean; @@ -43,6 +46,9 @@ interface SidebarProps { export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { const { t } = useTranslation("sidebar"); const { pendingCount } = usePendingPairingsCount(); + const role = useAuthStore((s) => s.role); + const { isCrossTenant } = useTenants(); + const isAdmin = role === "admin"; return (