diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 2da98dea..5334b959 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -17,7 +17,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore router := server.Router() // Phase 1: Core methods - chatMethods := methods.NewChatMethods(agents, sessStore, server.RateLimiter(), msgBus) + chatMethods := methods.NewChatMethods(agents, sessStore, cfg, server.RateLimiter(), msgBus) chatMethods.Register(router) methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router) methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router) diff --git a/internal/gateway/methods/access.go b/internal/gateway/methods/access.go index 566f1817..357afc33 100644 --- a/internal/gateway/methods/access.go +++ b/internal/gateway/methods/access.go @@ -1,9 +1,15 @@ package methods import ( + "context" "slices" + "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/pkg/protocol" ) // canSeeAll checks if user has full data visibility (admin role OR owner user). @@ -16,3 +22,23 @@ func canSeeAll(role permissions.Role, ownerIDs []string, userID string) bool { } return false } + +// requireSessionOwner verifies the caller owns the session identified by key. +// Returns true if the caller is authorized (owner, admin, or matching user). +// On failure, sends an error response and returns false. +func requireSessionOwner(ctx context.Context, sessions store.SessionStore, cfg *config.Config, client *gateway.Client, reqID string, key string) bool { + if canSeeAll(client.Role(), cfg.Gateway.OwnerIDs, client.UserID()) { + return true + } + locale := store.LocaleFromContext(ctx) + sess := sessions.Get(ctx, key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(reqID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", key))) + return false + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(reqID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return false + } + return true +} diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index 2cb57955..3fbdfed8 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" + "github.com/nextlevelbuilder/goclaw/internal/config" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/gateway" @@ -23,15 +24,16 @@ 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 + cfg *config.Config + rateLimiter *gateway.RateLimiter + eventBus bus.EventPublisher + postTurn tools.PostTurnProcessor } -func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods { - return &ChatMethods{agents: agents, sessions: sess, rateLimiter: rl, eventBus: eventBus} +func NewChatMethods(agents *agent.Router, sess store.SessionStore, cfg *config.Config, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods { + return &ChatMethods{agents: agents, sessions: sess, cfg: cfg, rateLimiter: rl, eventBus: eventBus} } // SetPostTurnProcessor sets the post-turn processor for team task dispatch. @@ -50,12 +52,18 @@ func (m *ChatMethods) Register(router *gateway.MethodRouter) { // handleSessionStatus returns the running state and activity for a session. // Used by the frontend to restore UI state after switching between sessions. -func (m *ChatMethods) handleSessionStatus(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *ChatMethods) handleSessionStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) var params struct { SessionKey string `json:"sessionKey"` } if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.SessionKey == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sessionKey required")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sessionKey"))) + return + } + + // Ownership check: non-admin users can only query their own sessions. + if !requireSessionOwner(ctx, m.sessions, m.cfg, client, req.ID, params.SessionKey) { return } @@ -316,6 +324,11 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client, sessionKey = sessions.BuildWSSessionKey(params.AgentID, uuid.NewString()) } + // Ownership check: non-admin users can only read their own session history. + if params.SessionKey != "" && !requireSessionOwner(ctx, m.sessions, m.cfg, client, req.ID, sessionKey) { + return + } + history := m.sessions.GetHistory(ctx, sessionKey) // Sign file URLs before delivery — sessions store clean paths. @@ -355,6 +368,11 @@ func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client, return } + // Ownership check: non-admin users can only inject into their own sessions. + if !requireSessionOwner(ctx, m.sessions, m.cfg, client, req.ID, params.SessionKey) { + return + } + // Truncate label if len(params.Label) > 100 { params.Label = params.Label[:100] @@ -405,6 +423,17 @@ func (m *ChatMethods) handleAbort(ctx context.Context, client *gateway.Client, r return } + // Non-admin users must provide sessionKey for ownership verification. + if params.SessionKey == "" && params.RunID != "" && !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sessionKey"))) + return + } + + // Ownership check: non-admin users can only abort their own sessions. + if params.SessionKey != "" && !requireSessionOwner(ctx, m.sessions, m.cfg, client, req.ID, params.SessionKey) { + return + } + var abortedIDs []string if params.RunID != "" {