diff --git a/cmd/gateway.go b/cmd/gateway.go index 2543f84e..0b5a75f5 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -522,7 +522,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(store.WithCrossTenant(context.Background())); err != nil { + if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) } } @@ -684,7 +684,7 @@ func runGateway() { if err != nil { return } - team, err := notifyTeamStore.GetTeam(store.WithCrossTenant(context.Background()), teamUUID) + team, err := notifyTeamStore.GetTeamUnscoped(context.Background(), teamUUID) if err != nil || team == nil { return } @@ -726,7 +726,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(store.WithCrossTenant(context.Background()), team.LeadAgentID); err == nil { + if la, err := notifyAgentStore.GetByIDUnscoped(context.Background(), team.LeadAgentID); err == nil { leadAgentKey = la.AgentKey } } diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 2feec5be..7de54f3b 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -25,7 +25,6 @@ func resolveEmbeddingProvider( providerReg *providers.Registry, sysConfigs store.SystemConfigStore, ) memory.EmbeddingProvider { - crossCtx := store.WithCrossTenant(context.Background()) // for provider store (list all) masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) // for system_configs (tenant-scoped) // 1. System config: embedding.provider (set via UI / API) @@ -39,7 +38,7 @@ func resolveEmbeddingProvider( if sysModel != "" { mcfg = &config.MemoryConfig{EmbeddingModel: sysModel} } - p := resolveEmbeddingFromDB(crossCtx, providerStore, name, mcfg, providerReg) + p := resolveEmbeddingFromDB(masterCtx, providerStore, name, mcfg, providerReg) if p != nil { slog.Info("embedding provider from system_configs", "name", name, "model", p.Model()) return p @@ -49,7 +48,7 @@ func resolveEmbeddingProvider( } // 2. Auto-detect: scan DB providers for first with settings.embedding.enabled - allProviders, err := providerStore.ListProviders(crossCtx) + allProviders, err := providerStore.ListAllProviders(context.Background()) if err != nil { slog.Warn("failed to list providers for embedding auto-detect", "error", err) return nil diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index fc055513..b3596f8e 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(store.WithCrossTenant(context.Background())) + go instanceLoader.Reload(context.Background()) }) } @@ -197,7 +197,7 @@ func wireChannelEventSubscribers( if err != nil { return } - all, err := ciStore.ListAll(store.WithCrossTenant(context.Background())) + all, err := ciStore.ListAllInstances(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(store.WithCrossTenant(context.Background()), inst.ID, map[string]any{"enabled": false}); err != nil { + if err := ciStore.Update(store.WithTenantID(context.Background(), inst.TenantID), 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_managed.go b/cmd/gateway_managed.go index 5c6db2f7..6f1adb3c 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -479,7 +479,8 @@ func wireExtras( return } // Re-register from DB if provider still exists and is ACP type - p, err := stores.Providers.GetProviderByName(context.Background(), payload.Key) + provCtx := store.WithTenantID(context.Background(), event.TenantID) + p, err := stores.Providers.GetProviderByName(provCtx, payload.Key) if err != nil { // Provider was deleted or not found — already unregistered by handler return diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 6b4ed746..520d5069 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -233,8 +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 := store.WithCrossTenant(context.Background()) - dbProviders, err := provStore.ListProviders(ctx) + dbProviders, err := provStore.ListAllProviders(context.Background()) if err != nil { slog.Warn("failed to load providers from DB", "error", err) return diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index b0dc71c7..0f2b2ca9 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -492,7 +492,7 @@ func setupSkillsSystem( skillSearchTool := tools.NewSkillSearchTool(skillsLoader) toolsReg.Register(skillSearchTool) toolsReg.Register(tools.NewUseSkillTool()) - slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(store.WithCrossTenant(context.Background())))) + slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(context.Background()))) // Wire skills-store directory into filesystem loader so agents // can discover uploaded skills in their system prompt and BM25 search index. diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go index 01a8ffad..9e7b4774 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -18,11 +18,9 @@ func syncSystemConfigs(sc store.SystemConfigStore, ts store.TenantStore, cfg *co return } - crossCtx := store.WithCrossTenant(context.Background()) - // Enumerate tenants and seed each one if ts != nil { - tenants, err := ts.ListTenants(crossCtx) + tenants, err := ts.ListTenants(context.Background()) if err != nil { slog.Warn("failed to list tenants for system config seed", "error", err) // Fall back to master tenant only diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 5ab32bf3..15336173 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -423,7 +423,7 @@ func resolveTenantSlug(ts store.TenantStore, tenantID uuid.UUID) string { if ts == nil { return tenantID.String() } - tenant, err := ts.GetTenant(store.WithCrossTenant(context.Background()), tenantID) + tenant, err := ts.GetTenant(context.Background(), tenantID) if err != nil || tenant == nil { return tenantID.String() } diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index 5eb446e0..aa964e25 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -63,6 +63,9 @@ 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) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { + return nil, nil +} func (s *seedStubStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { return nil, nil } diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index 86f16240..51671096 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -80,7 +80,7 @@ func (l *InstanceLoader) LoadAll(ctx context.Context) error { l.mu.Lock() defer l.mu.Unlock() - instances, err := l.store.ListEnabled(ctx) + instances, err := l.store.ListAllEnabled(ctx) if err != nil { return err } @@ -122,8 +122,8 @@ func (l *InstanceLoader) Reload(ctx context.Context) { // Brief pause to let external APIs (e.g., Telegram getUpdates) release polling locks. time.Sleep(500 * time.Millisecond) - // Reload from DB - instances, err := l.store.ListEnabled(ctx) + // Reload from DB (all tenants — server-internal) + instances, err := l.store.ListAllEnabled(ctx) if err != nil { slog.Error("failed to reload channel instances", "error", err) return @@ -223,10 +223,12 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns } // Resolve agent_key from UUID — the routing system (Router, session keys) uses agent_key, not UUID. + // Use the instance's tenant_id to scope the agent lookup. + instCtx := store.WithTenantID(ctx, inst.TenantID) var ag *store.AgentData if base, ok := ch.(interface{ SetAgentID(string) }); ok { var err error - ag, err = l.agentStore.GetByID(ctx, inst.AgentID) + ag, err = l.agentStore.GetByID(instCtx, inst.AgentID) if err != nil { return fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err) } diff --git a/internal/channels/quota.go b/internal/channels/quota.go index 6c9a9684..ab246a63 100644 --- a/internal/channels/quota.go +++ b/internal/channels/quota.go @@ -299,9 +299,6 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult // 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 diff --git a/internal/gateway/client.go b/internal/gateway/client.go index f9d9744c..d8e1caa1 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -38,10 +38,9 @@ type Client struct { // 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) + tenantID uuid.UUID // resolved tenant; always concrete after connect + 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 { @@ -213,8 +212,8 @@ 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 } +// IsOwner returns true if the client has the owner role (tenant management + full access). +func (c *Client) IsOwner() bool { return c.role == permissions.RoleOwner } // HasScope reports whether the client has the given scope. func (c *Client) HasScope(scope permissions.Scope) bool { diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go index 53d6c634..1615ac86 100644 --- a/internal/gateway/event_filter.go +++ b/internal/gateway/event_filter.go @@ -25,24 +25,21 @@ func clientCanReceiveEvent(c *Client, event bus.Event) bool { return true } - // Tenant isolation: fail-closed, 3-mode filtering. + // Tenant isolation: fail-closed, 2-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 - } + // All clients (including owners) always have a concrete tenantID after connect. + // Mode 1: Owner role (role=owner) → see tenant X events + system events (tenantID=Nil on event) + // Mode 2: Regular client (tenantID=X) → ONLY tenant X events (fail-closed) + if c.tenantID == uuid.Nil { + return false // fail-closed: no tenant assigned + } + // 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 owner role can see unscoped events + if event.TenantID == uuid.Nil && !c.IsOwner() { + return false // fail-closed: regular users blocked from unscoped events } // Admin sees everything (when not tenant-scoped, handled above). diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go index 67815c1d..b5c054f9 100644 --- a/internal/gateway/methods/agents_create.go +++ b/internal/gateway/methods/agents_create.go @@ -86,7 +86,7 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant. var tenantID uuid.UUID - if client.IsCrossTenant() { + if client.IsOwner() { if params.TenantID == "" { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "tenant_id"))) return diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index 0bfb06ed..ad440c61 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -31,6 +31,9 @@ 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) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { + return nil, nil +} func (s *createCaptureStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { return nil, nil } diff --git a/internal/gateway/methods/api_keys.go b/internal/gateway/methods/api_keys.go index 1ca6ec93..beec1b63 100644 --- a/internal/gateway/methods/api_keys.go +++ b/internal/gateway/methods/api_keys.go @@ -101,7 +101,7 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien // Resolve tenant_id based on caller type. var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB) - if client.IsCrossTenant() { + if client.IsOwner() { if params.TenantID != "" { tid, err := uuid.Parse(params.TenantID) if err != nil { diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index a9c3674b..91fdbae3 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -36,16 +36,16 @@ func (m *ConfigMethods) SetSystemConfigSync(fn func(ctx context.Context, cfg *co } func (m *ConfigMethods) Register(router *gateway.MethodRouter) { - 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)) + router.Register(protocol.MethodConfigGet, m.requireOwner(m.handleGet)) + router.Register(protocol.MethodConfigApply, m.requireOwner(m.handleApply)) + router.Register(protocol.MethodConfigPatch, m.requireOwner(m.handlePatch)) + router.Register(protocol.MethodConfigSchema, m.requireOwner(m.handleSchema)) } -// requireCrossTenant wraps a handler to only allow cross-tenant (owner/system) users. -func (m *ConfigMethods) requireCrossTenant(next gateway.MethodHandler) gateway.MethodHandler { +// requireOwner wraps a handler to only allow owner-role users. +func (m *ConfigMethods) requireOwner(next gateway.MethodHandler) gateway.MethodHandler { return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if !client.IsCrossTenant() { + if !client.IsOwner() { locale := store.LocaleFromContext(ctx) client.SendResponse(protocol.NewErrorResponse( req.ID, protocol.ErrUnauthorized, diff --git a/internal/gateway/methods/tenants.go b/internal/gateway/methods/tenants.go index 6108315d..7aff4966 100644 --- a/internal/gateway/methods/tenants.go +++ b/internal/gateway/methods/tenants.go @@ -46,7 +46,7 @@ func (m *TenantsMethods) Register(router *gateway.MethodRouter) { func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() { + if !client.IsOwner() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list"))) return } @@ -65,7 +65,7 @@ func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, func (m *TenantsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() { + if !client.IsOwner() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get"))) return } @@ -96,7 +96,7 @@ func (m *TenantsMethods) handleGet(ctx context.Context, client *gateway.Client, func (m *TenantsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) { + if !client.IsOwner() && !client.HasScope(permissions.ScopeProvision) { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create"))) return } @@ -154,7 +154,7 @@ func (m *TenantsMethods) handleCreate(ctx context.Context, client *gateway.Clien func (m *TenantsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() { + if !client.IsOwner() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update"))) return } @@ -207,7 +207,7 @@ func (m *TenantsMethods) handleUpdate(ctx context.Context, client *gateway.Clien func (m *TenantsMethods) handleUsersList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() { + if !client.IsOwner() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list"))) return } @@ -242,7 +242,7 @@ func (m *TenantsMethods) handleUsersList(ctx context.Context, client *gateway.Cl func (m *TenantsMethods) handleUsersAdd(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) { + if !client.IsOwner() && !client.HasScope(permissions.ScopeProvision) { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add"))) return } @@ -293,7 +293,7 @@ func (m *TenantsMethods) handleUsersAdd(ctx context.Context, client *gateway.Cli func (m *TenantsMethods) handleUsersRemove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - if !client.IsCrossTenant() { + if !client.IsOwner() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove"))) return } @@ -351,8 +351,8 @@ func (m *TenantsMethods) handleMine(ctx context.Context, client *gateway.Client, Status string `json:"status"` } - // Cross-tenant admin: return all tenants with "owner" role - if client.IsCrossTenant() { + // Owner: return all tenants with "owner" role + if client.IsOwner() { tenants, err := m.tenantStore.ListTenants(ctx) if err != nil { slog.Error("tenants.mine failed (cross-tenant)", "error", err) diff --git a/internal/gateway/router.go b/internal/gateway/router.go index ddb93dc3..dd36568a 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -78,8 +78,7 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol } // 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. + // All connect paths guarantee client.tenantID is set (owner defaults to MasterTenantID). ctx = store.WithLocale(ctx, i18n.Normalize(client.locale)) if client.TenantID() != uuid.Nil { ctx = store.WithTenantID(ctx, client.TenantID()) @@ -125,21 +124,16 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.authenticated = true client.userID = params.UserID - // 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. + // Owner IDs get RoleOwner; others keep RoleAdmin but are scoped to their tenant. isOwner := isOwnerID(params.UserID, r.server.cfg.Gateway.OwnerIDs) - client.crossTenant = isOwner - if isOwner { - // Cross-tenant admin can narrow scope to a specific tenant + client.role = permissions.RoleOwner + // Owner can narrow scope to a specific tenant via param 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 } @@ -183,8 +177,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.userID = params.UserID } if keyData.TenantID == uuid.Nil { - client.crossTenant = true - // Cross-tenant API key can narrow scope to a specific tenant + // API key with no tenant → owner scope + client.role = permissions.RoleOwner apiKeyScope := params.TenantID if apiKeyScope == "" { apiKeyScope = params.TenantScope // backward compat @@ -196,7 +190,6 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p slog.Debug("security.ws_connect_resolved", "client", client.id, "role", string(client.role), - "cross_tenant", client.crossTenant, "tenant_id", client.tenantID.String(), ) } else { @@ -294,11 +287,11 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p 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, + "protocol": protocol.ProtocolVersion, + "role": string(client.role), + "user_id": client.userID, + "tenant_id": client.tenantID.String(), + "is_owner": client.IsOwner(), "server": map[string]any{ "name": "goclaw", "version": r.server.version, @@ -388,15 +381,13 @@ func (r *MethodRouter) getUserTenantRole(ctx context.Context, tenantID uuid.UUID 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. +// applyTenantScope narrows an owner client's data scope to a specific tenant. +// Sets client.tenantID so the router injects WithTenantID 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 { @@ -405,11 +396,10 @@ func (r *MethodRouter) applyTenantScope(ctx context.Context, client *Client, ten t, err = r.tenantStore.GetTenantBySlug(ctx, tenantVal) } if err != nil || t == nil { - slog.Debug("tenant scope not resolved, keeping unscoped", "value", tenantVal) + slog.Debug("tenant scope not resolved, keeping master", "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) } diff --git a/internal/heartbeat/ticker.go b/internal/heartbeat/ticker.go index 19a7742b..e92d819c 100644 --- a/internal/heartbeat/ticker.go +++ b/internal/heartbeat/ticker.go @@ -153,10 +153,9 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) { agentIDStr := hb.AgentID.String() // Resolve agent to get tenant scope + display key. - // System-level lookup (cross-tenant) since ticker is a global scheduler. - sysCtx := store.WithCrossTenant(context.Background()) + // Unscoped lookup since ticker is a global scheduler. agentKey := agentIDStr - ag, agErr := t.agents.GetByID(sysCtx, hb.AgentID) + ag, agErr := t.agents.GetByIDUnscoped(context.Background(), hb.AgentID) if agErr != nil { slog.Warn("heartbeat.agent_not_found", "agent_id", agentIDStr, "error", agErr) return diff --git a/internal/http/agents.go b/internal/http/agents.go index 66231fdf..6421cec0 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -121,7 +121,7 @@ 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 store.IsOwnerRole(r.Context()) { if req.TenantID == uuid.Nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tenant_id")}) return diff --git a/internal/http/api_keys.go b/internal/http/api_keys.go index bf4adea1..54e474e6 100644 --- a/internal/http/api_keys.go +++ b/internal/http/api_keys.go @@ -98,7 +98,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { // Resolve tenant_id based on caller type. var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB) - if store.IsCrossTenant(r.Context()) { + if store.IsOwnerRole(r.Context()) { if input.TenantID != "" { tid, err := uuid.Parse(input.TenantID) if err != nil { diff --git a/internal/http/auth.go b/internal/http/auth.go index c3d284e8..24c03d91 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -108,7 +108,7 @@ func InitPairingAuth(ps store.PairingStore) { } // InitOwnerIDs sets the configured owner user IDs for HTTP auth. -// Only owners get cross-tenant access with gateway token; others are tenant-scoped. +// Owners get RoleOwner with gateway token; others get RoleAdmin scoped to their tenant. func InitOwnerIDs(ids []string) { pkgOwnerIDs = ids } @@ -158,8 +158,7 @@ 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 + TenantID uuid.UUID // resolved tenant; always concrete after resolution } // resolveAuth determines the caller's role from the request. @@ -177,10 +176,14 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { if pkgGatewayToken != "" && tokenMatch(bearer, pkgGatewayToken) { userID := extractUserID(r) isOwner := isHTTPOwnerID(userID, pkgOwnerIDs) - res := authResult{Role: permissions.RoleAdmin, Authenticated: true, CrossTenant: isOwner} + role := permissions.RoleAdmin + if isOwner { + role = permissions.RoleOwner + } + res := authResult{Role: role, Authenticated: true} tenantVal := r.Header.Get("X-GoClaw-Tenant-Id") - if isOwner && tenantVal != "" && pkgTenantCache != nil { - // Cross-tenant admin can narrow scope via header + if pkgTenantCache != nil && tenantVal != "" { + // Resolve tenant from header (works for both owner and non-owner) if tid, err := uuid.Parse(tenantVal); err == nil { if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil { res.TenantID = t.ID @@ -188,20 +191,9 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { } 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 - } + } + if res.TenantID == uuid.Nil { + res.TenantID = store.MasterTenantID } return res } @@ -209,7 +201,9 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { if keyData, role := ResolveAPIKey(r.Context(), bearer); role != "" { res := authResult{Role: role, Authenticated: true, KeyData: keyData} if keyData.TenantID == uuid.Nil { - res.CrossTenant = true + // API key with no tenant → owner scope, default to master + res.Role = permissions.RoleOwner + res.TenantID = store.MasterTenantID } else { res.TenantID = keyData.TenantID } @@ -287,38 +281,16 @@ func requireAuth(minRole permissions.Role, next http.HandlerFunc) http.HandlerFu 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(), - ) + tenantID := auth.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID } + ctx = store.WithTenantID(ctx, tenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_id", tenantID.String(), + ) next(w, r.WithContext(ctx)) } } @@ -366,13 +338,11 @@ func requireAuthBearer(minRole permissions.Role, bearer string, w http.ResponseW 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) + tenantID := auth.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID } + ctx = store.WithTenantID(ctx, tenantID) return r.WithContext(ctx), true } diff --git a/internal/http/oauth_test.go b/internal/http/oauth_test.go index 885b9d34..372a7ccd 100644 --- a/internal/http/oauth_test.go +++ b/internal/http/oauth_test.go @@ -83,6 +83,14 @@ func (m *mockProviderStore) DeleteProvider(_ context.Context, id uuid.UUID) erro return fmt.Errorf("not found") } +func (m *mockProviderStore) ListAllProviders(_ context.Context) ([]store.LLMProviderData, error) { + var out []store.LLMProviderData + for _, p := range m.providers { + out = append(out, *p) + } + return out, nil +} + type mockSecretsStore struct { data map[string]string } diff --git a/internal/http/skills.go b/internal/http/skills.go index ed11cca8..63204732 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -94,7 +94,7 @@ func (h *SkillsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc // 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) { + if store.IsOwnerRole(ctx) { return true } tid := store.TenantIDFromContext(ctx) @@ -325,9 +325,11 @@ type depResult struct { Missing []string `json:"missing,omitempty"` } -// rescanAndUpdate re-checks all skills and updates their status + missing deps in DB. +// rescanAndUpdate re-checks system skills and updates their status + missing deps in DB. +// Only system skills have filesystem dependencies that need rescanning. func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { - allSkills := h.skills.ListAllSkills(store.WithCrossTenant(context.Background())) + masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) + allSkills := h.skills.ListAllSystemSkills(context.Background()) for _, sk := range allSkills { manifest := h.scanWithFallback(sk) @@ -341,7 +343,7 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { // 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"}) + _ = h.skills.UpdateSkill(masterCtx, 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) @@ -356,11 +358,11 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { switch { case ok && sk.Status == "archived": - _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"}) results = append(results, depResult{Slug: sk.Slug, Status: "active"}) updated++ case !ok && sk.Status == "active": - _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "archived"}) + _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "archived"}) results = append(results, depResult{Slug: sk.Slug, Status: "archived", Missing: missing}) updated++ case !ok: diff --git a/internal/http/tenants.go b/internal/http/tenants.go index 5b97dd6b..473c6970 100644 --- a/internal/http/tenants.go +++ b/internal/http/tenants.go @@ -44,7 +44,7 @@ func (h *TenantsHandler) RegisterRoutes(mux *http.ServeMux) { func (h *TenantsHandler) handleList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")}) return } @@ -63,7 +63,7 @@ func (h *TenantsHandler) handleList(w http.ResponseWriter, r *http.Request) { func (h *TenantsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")}) return } @@ -118,7 +118,7 @@ func (h *TenantsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { func (h *TenantsHandler) handleGet(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")}) return } @@ -139,7 +139,7 @@ func (h *TenantsHandler) handleGet(w http.ResponseWriter, r *http.Request) { func (h *TenantsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")}) return } @@ -189,7 +189,7 @@ func (h *TenantsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { func (h *TenantsHandler) handleUsersList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")}) return } @@ -214,7 +214,7 @@ func (h *TenantsHandler) handleUsersList(w http.ResponseWriter, r *http.Request) func (h *TenantsHandler) handleUsersAdd(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")}) return } @@ -263,7 +263,7 @@ func (h *TenantsHandler) handleUsersAdd(w http.ResponseWriter, r *http.Request) func (h *TenantsHandler) handleUsersRemove(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")}) return } diff --git a/internal/http/usage.go b/internal/http/usage.go index 901573fe..d07108b6 100644 --- a/internal/http/usage.go +++ b/internal/http/usage.go @@ -186,7 +186,7 @@ func (h *UsageHandler) queryLiveHour(r *http.Request, from, to time.Time, q stor idx := 3 // Tenant isolation: scope to caller's tenant - if !store.IsCrossTenant(r.Context()) { + if !store.IsOwnerRole(r.Context()) { tid := store.TenantIDFromContext(r.Context()) if tid != uuid.Nil { query += fmt.Sprintf(" AND tenant_id = $%d", idx) diff --git a/internal/oauth/token_test.go b/internal/oauth/token_test.go index afc11a7d..3dd15998 100644 --- a/internal/oauth/token_test.go +++ b/internal/oauth/token_test.go @@ -82,6 +82,14 @@ func (m *mockProviderStore) DeleteProvider(_ context.Context, id uuid.UUID) erro return fmt.Errorf("not found") } +func (m *mockProviderStore) ListAllProviders(_ context.Context) ([]store.LLMProviderData, error) { + var out []store.LLMProviderData + for _, p := range m.providers { + out = append(out, *p) + } + return out, nil +} + type mockSecretsStore struct { data map[string]string } diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 0a95cdb8..d890e51e 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -23,6 +23,7 @@ import ( type Role string const ( + RoleOwner Role = "owner" // Tenant management + full access (superset of admin) RoleAdmin Role = "admin" // Full access to all methods RoleOperator Role = "operator" // Read + write access (no admin operations) RoleViewer Role = "viewer" // Read-only access @@ -221,6 +222,8 @@ func HasMinRole(role, required Role) bool { func roleLevel(r Role) int { switch r { + case RoleOwner: + return 4 case RoleAdmin: return 3 case RoleOperator: diff --git a/internal/skills/seeder.go b/internal/skills/seeder.go index 3c07975b..a8d71fc5 100644 --- a/internal/skills/seeder.go +++ b/internal/skills/seeder.go @@ -177,7 +177,7 @@ func (s *Seeder) CheckDepsAsync(skills []seededSkill, msgBus *bus.MessageBus) { status := "active" if !ok { status = "archived" - _ = s.store.UpdateSkill(store.WithCrossTenant(context.Background()), sk.id, map[string]interface{}{"status": "archived"}) + _ = s.store.UpdateSkill(store.WithTenantID(context.Background(), store.MasterTenantID), sk.id, map[string]interface{}{"status": "archived"}) s.store.BumpVersion() slog.Warn("seeder: skill deps missing", "slug", sk.slug, "missing", FormatMissing(missing)) } diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index a05c2cb8..b7e20fed 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -316,6 +316,7 @@ 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) + GetByIDUnscoped(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 diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 7e1d6acd..63d3307b 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -52,6 +52,8 @@ type ChannelInstanceStore interface { Delete(ctx context.Context, id uuid.UUID) error ListEnabled(ctx context.Context) ([]ChannelInstanceData, error) ListAll(ctx context.Context) ([]ChannelInstanceData, error) + ListAllInstances(ctx context.Context) ([]ChannelInstanceData, error) + ListAllEnabled(ctx context.Context) ([]ChannelInstanceData, error) ListPaged(ctx context.Context, opts ChannelInstanceListOpts) ([]ChannelInstanceData, error) CountInstances(ctx context.Context, opts ChannelInstanceListOpts) (int, error) } diff --git a/internal/store/context.go b/internal/store/context.go index 5ed8fced..4994288e 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -183,17 +183,30 @@ func TenantIDFromContext(ctx context.Context) uuid.UUID { } // WithCrossTenant returns a context flagged for cross-tenant access. -// Used by owner/system admin callers who can access all tenants. +// Deprecated: Only used by skills store (is_system dual-visibility pattern). +// All other callers must use explicit tenant context or unscoped store methods. func WithCrossTenant(ctx context.Context) context.Context { return context.WithValue(ctx, CrossTenantKey, true) } // IsCrossTenant returns true if the caller has cross-tenant access. +// Deprecated: Only used by skills store and inline pg/*.go tenant checks. +// Permission guards should use IsOwnerRole(). SQL queries use tenantClauseN() (no bypass). func IsCrossTenant(ctx context.Context) bool { v, _ := ctx.Value(CrossTenantKey).(bool) return v } +// IsOwnerRole returns true if the caller has the "owner" role. +// Replaces IsCrossTenant for permission guards. +func IsOwnerRole(ctx context.Context) bool { + return RoleFromContext(ctx) == string(RoleOwner) +} + +// RoleOwner is the owner role constant for context checks. +// Must match permissions.RoleOwner. +const RoleOwner = "owner" + // 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) diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index e66774f2..b9c5b5c2 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -228,10 +228,11 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str // Regenerate embedding when frontmatter changes if _, hasFrontmatter := updates["frontmatter"]; hasFrontmatter && s.embProvider != nil { + bgCtx := store.WithTenantID(context.Background(), store.TenantIDFromContext(ctx)) go func() { - ag, agErr := s.GetByID(context.Background(), id) + ag, agErr := s.GetByID(bgCtx, id) if agErr == nil { - s.generateAgentEmbedding(context.Background(), id, ag.DisplayName, ag.Frontmatter) + s.generateAgentEmbedding(bgCtx, id, ag.DisplayName, ag.Frontmatter) } }() } diff --git a/internal/store/pg/agents_unscoped.go b/internal/store/pg/agents_unscoped.go new file mode 100644 index 00000000..836779b1 --- /dev/null +++ b/internal/store/pg/agents_unscoped.go @@ -0,0 +1,22 @@ +package pg + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetByIDUnscoped returns an agent by ID without tenant filtering. Server-internal only. +func (s *PGAgentStore) GetByIDUnscoped(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) + d, err := scanAgentRow(row) + if err != nil { + return nil, fmt.Errorf("agent not found: %s", id) + } + return d, nil +} diff --git a/internal/store/pg/channel_instances_unscoped.go b/internal/store/pg/channel_instances_unscoped.go new file mode 100644 index 00000000..bcc36d64 --- /dev/null +++ b/internal/store/pg/channel_instances_unscoped.go @@ -0,0 +1,27 @@ +package pg + +import ( + "context" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ListAllInstances returns all channel instances across all tenants. Server-internal only. +func (s *PGChannelInstanceStore) ListAllInstances(ctx context.Context) ([]store.ChannelInstanceData, error) { + q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances ORDER BY name` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} + +// ListAllEnabled returns enabled channel instances across all tenants. Server-internal only. +func (s *PGChannelInstanceStore) ListAllEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) { + q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = true ORDER BY name` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + return s.scanInstances(rows) +} diff --git a/internal/store/pg/helpers.go b/internal/store/pg/helpers.go index 9a5c16e0..3c46bc3a 100644 --- a/internal/store/pg/helpers.go +++ b/internal/store/pg/helpers.go @@ -213,12 +213,8 @@ func tableHasUpdatedAt(table string) bool { // --- 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). +// Always requires tenant_id in context (fail-closed). No cross-tenant bypass. 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") @@ -236,9 +232,6 @@ func tenantClauseNAlias(ctx context.Context, paramN int, alias string) (clause s return "", nil, fmt.Errorf("invalid table alias: %q", alias) } } - if store.IsCrossTenant(ctx) { - return "", nil, nil - } tid := store.TenantIDFromContext(ctx) if tid == uuid.Nil { return "", nil, fmt.Errorf("tenant_id required") diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go index 18b10c35..fdb40731 100644 --- a/internal/store/pg/providers.go +++ b/internal/store/pg/providers.go @@ -124,6 +124,29 @@ func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProvide return result, nil } +// ListAllProviders returns all providers across all tenants. Server-internal only. +func (s *PGProviderStore) ListAllProviders(ctx context.Context) ([]store.LLMProviderData, error) { + 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 ORDER BY name` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.LLMProviderData + 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, &p.TenantID); err != nil { + continue + } + p.APIKey = s.decryptKey(apiKey, p.Name) + result = append(result, p) + } + return result, nil +} + func (s *PGProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, updates map[string]any) error { if apiKey, ok := updates["api_key"]; ok && s.encKey != "" { if keyStr, ok := apiKey.(string); ok && keyStr != "" { diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go index fbb5f84d..b406ddf5 100644 --- a/internal/store/pg/skills.go +++ b/internal/store/pg/skills.go @@ -61,7 +61,7 @@ func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} } func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { currentVer := s.version.Load() tid := store.TenantIDFromContext(ctx) - if tid == uuid.Nil && !store.IsCrossTenant(ctx) { + if tid == uuid.Nil { tid = store.MasterTenantID } @@ -78,18 +78,10 @@ func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { // 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. // 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) - } + 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 } @@ -130,16 +122,42 @@ func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { return result } -// ListAllSkills returns all enabled skills regardless of status (for admin operations like rescan-deps). +// ListAllSkills returns system skills + custom skills for the given tenant (for admin operations like rescan-deps). // Disabled skills are excluded — no point scanning or updating them. func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } 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`) + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE enabled = true AND status != 'deleted' AND (is_system = true OR tenant_id = $1) + ORDER BY name`, tid) if err != nil { return nil } defer rows.Close() + return s.scanSkillInfoList(rows) +} + +// ListAllSystemSkills returns only system skills (for startup dependency scanning). +// No tenant filter — system skills belong to MasterTenantID and are globally visible. +func (s *PGSkillStore) ListAllSystemSkills(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 is_system = true AND enabled = true AND status != 'deleted' + ORDER BY name`) + if err != nil { + return nil + } + defer rows.Close() + + return s.scanSkillInfoList(rows) +} + +// scanSkillInfoList scans rows into a []SkillInfo slice. Shared by list methods. +func (s *PGSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo { var result []store.SkillInfo for rows.Next() { var id uuid.UUID @@ -163,12 +181,13 @@ func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { result = append(result, info) } if err := rows.Err(); err != nil { - slog.Warn("ListAllSkills: rows iteration error", "error", err) + slog.Warn("scanSkillInfoList: rows iteration error", "error", err) } return result } // StoreMissingDeps persists the missing_deps list for a skill into the deps JSONB column. +// Only updates system skills unscoped; custom skills require tenant match. func (s *PGSkillStore) StoreMissingDeps(id uuid.UUID, missing []string) error { if missing == nil { missing = []string{} @@ -177,8 +196,10 @@ func (s *PGSkillStore) StoreMissingDeps(id uuid.UUID, missing []string) error { if err != nil { return err } + // System skills can be updated without tenant; custom skills need tenant scope. + // Use is_system check to ensure cross-tenant safety for custom skills. _, err = s.db.Exec( - `UPDATE skills SET deps = $1, updated_at = NOW() WHERE id = $2`, + `UPDATE skills SET deps = $1, updated_at = NOW() WHERE id = $2 AND is_system = true`, encoded, id, ) if err == nil { diff --git a/internal/store/pg/teams_unscoped.go b/internal/store/pg/teams_unscoped.go new file mode 100644 index 00000000..a6e6ef00 --- /dev/null +++ b/internal/store/pg/teams_unscoped.go @@ -0,0 +1,16 @@ +package pg + +import ( + "context" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetTeamUnscoped returns a team by ID without tenant filtering. Server-internal only. +func (s *PGTeamStore) GetTeamUnscoped(ctx context.Context, id uuid.UUID) (*store.TeamData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, id) + return scanTeamRow(row) +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index c86c2283..74ff6334 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -108,6 +108,7 @@ type ProviderStore interface { GetProvider(ctx context.Context, id uuid.UUID) (*LLMProviderData, error) GetProviderByName(ctx context.Context, name string) (*LLMProviderData, error) ListProviders(ctx context.Context) ([]LLMProviderData, error) + ListAllProviders(ctx context.Context) ([]LLMProviderData, error) UpdateProvider(ctx context.Context, id uuid.UUID, updates map[string]any) error DeleteProvider(ctx context.Context, id uuid.UUID) error } diff --git a/internal/store/team_store.go b/internal/store/team_store.go index c64bf865..5c50ff81 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -197,6 +197,7 @@ type TeamStore interface { // Team CRUD CreateTeam(ctx context.Context, team *TeamData) error GetTeam(ctx context.Context, teamID uuid.UUID) (*TeamData, error) + GetTeamUnscoped(ctx context.Context, id uuid.UUID) (*TeamData, error) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error DeleteTeam(ctx context.Context, teamID uuid.UUID) error ListTeams(ctx context.Context) ([]TeamData, error) diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index 63d059e9..62f3cf4b 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -45,6 +45,9 @@ 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) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { + return nil, nil +} func (s *stubAgentStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { return nil, nil } diff --git a/internal/tools/skill_search.go b/internal/tools/skill_search.go index 8d659e52..ec2e55c7 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(store.WithCrossTenant(context.Background())) + t.rebuildIndex(context.Background()) return t } diff --git a/internal/tools/subagent_spawn_tool.go b/internal/tools/subagent_spawn_tool.go index 6a309303..5a776399 100644 --- a/internal/tools/subagent_spawn_tool.go +++ b/internal/tools/subagent_spawn_tool.go @@ -100,13 +100,10 @@ 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. + // Validate tenant isolation: 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") - } + if store.TenantIDFromContext(ctx) == uuid.Nil { + return ErrorResult("spawn requires tenant context: no tenant ID found in request context") } task, _ := args["task"].(string) diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go index d2521129..73eb8d0f 100644 --- a/internal/tools/team_tool_cache.go +++ b/internal/tools/team_tool_cache.go @@ -108,12 +108,9 @@ func (m *TeamToolManager) InvalidateAgentCache() { // 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 "0:" + key } return tid.String() + ":" + key } diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts index 3b0d74af..46ea97a8 100644 --- a/ui/web/src/api/ws-client.ts +++ b/ui/web/src/api/ws-client.ts @@ -30,7 +30,7 @@ export class WsClient { tenantId = ""; tenantName = ""; tenantSlug = ""; - crossTenant = false; + isOwner = false; serverVersion = ""; private readonly maxReconnectDelay = 30_000; @@ -206,7 +206,7 @@ export class WsClient { tenant_id?: string; tenant_name?: string; tenant_slug?: string; - cross_tenant?: boolean; + is_owner?: boolean; server?: { name?: string; version?: string }; }>("connect", { token: this.getToken(), @@ -239,7 +239,7 @@ export class WsClient { this.tenantId = res?.tenant_id ?? ""; this.tenantName = res?.tenant_name ?? ""; this.tenantSlug = res?.tenant_slug ?? ""; - this.crossTenant = res?.cross_tenant ?? false; + this.isOwner = res?.is_owner ?? false; this.serverVersion = res?.server?.version ?? ""; this.onStateChange("connected"); } catch (e) { diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index c8af3a10..33fb6d4c 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -46,8 +46,8 @@ 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"; + const { isOwner } = useTenants(); + const isAdmin = role === "admin" || role === "owner"; return (