mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
refactor(security): remove cross-tenant bypass, enforce strict tenant isolation
- Add RoleOwner ("owner") to permissions hierarchy (above admin)
- Replace all IsCrossTenant/client.IsCrossTenant() permission guards with
IsOwnerRole/client.IsOwner() role-based checks (20+ locations)
- Remove cross-tenant bypass from tenantClauseN SQL helper — always adds
AND tenant_id = $N (fail-closed)
- Add explicit unscoped store methods for startup: ListAllProviders,
ListAllInstances, ListAllEnabled, GetByIDUnscoped, GetTeamUnscoped
- Remove CrossTenant from auth layer: owner users get role "owner" with
concrete tenant ID (MasterTenantID fallback), no bypass flag
- Remove crossTenant field from WS Client; connect response sends
is_owner instead of cross_tenant
- Simplify event filter from 3-mode to 2-mode (tenant-scoped + owner sees system events)
- Fix channel instance loader to use inst.TenantID for agent lookup
- Fix agent embedding goroutine to capture tenant from caller context
- Fix provider cache invalidation to use event.TenantID
- Separate skills store: ListAllSystemSkills (system only) vs
ListAllSkills (system + tenant-scoped), StoreMissingDeps restricted
to system skills only
- Frontend: isCrossTenant → isOwner, add "owner" to UserRole type,
RequireAdmin uses hasMinRole for owner > admin hierarchy
This commit is contained in:
1 parent
7143dcf32c
commit
7aa47d1baf
59 files changed
+365
-260
No files matched your search
+3
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-24
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+28
-58
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
+38
-17
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<aside
|
||||
@@ -120,14 +120,14 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
|
||||
{isAdmin && (
|
||||
<SidebarGroup label={t("groups.system")} collapsed={collapsed}>
|
||||
{isCrossTenant && (
|
||||
{isOwner && (
|
||||
<SidebarItem to={ROUTES.TENANTS} icon={Building2} label={t("nav.tenants")} collapsed={collapsed} />
|
||||
)}
|
||||
<SidebarItem to={ROUTES.PROVIDERS} icon={Cpu} label={t("nav.providers")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CLI_CREDENTIALS} icon={KeyRound} label={t("nav.cliCredentials")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.API_KEYS} icon={KeyRound} label={t("nav.apiKeys")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.PACKAGES} icon={Blocks} label={t("nav.packages")} collapsed={collapsed} />
|
||||
{isCrossTenant && (
|
||||
{isOwner && (
|
||||
<SidebarItem to={ROUTES.CONFIG} icon={Settings} label={t("nav.config")} collapsed={collapsed} />
|
||||
)}
|
||||
<SidebarItem to={ROUTES.APPROVALS} icon={ShieldCheck} label={t("nav.approvals")} collapsed={collapsed} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Topbar() {
|
||||
const { status: embStatus } = useEmbeddingStatus();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const role = useAuthStore((s) => s.role);
|
||||
const isAdmin = role === "admin";
|
||||
const isAdmin = role === "admin" || role === "owner";
|
||||
|
||||
const handleSidebarToggle = isMobile
|
||||
? () => setMobileSidebarOpen(true)
|
||||
@@ -122,7 +122,7 @@ function UserMenu() {
|
||||
const { t: tt } = useTranslation("tenants");
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const userId = useAuthStore((s) => s.userId);
|
||||
const { currentTenant, currentTenantName, tenants, isCrossTenant, isMultiTenant, currentTenantId } = useTenants();
|
||||
const { currentTenant, currentTenantName, tenants, isOwner, isMultiTenant, currentTenantId } = useTenants();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
@@ -131,9 +131,9 @@ function UserMenu() {
|
||||
const tenantLabel = currentTenant?.name || currentTenantName || "";
|
||||
|
||||
const handleSwitchTenant = (_tenantId: string, slug: string) => {
|
||||
// Cross-tenant admin: narrow scope to specific tenant
|
||||
// Non-cross-tenant: use tenant_hint for pairing
|
||||
if (isCrossTenant) {
|
||||
// Owner: narrow scope to specific tenant
|
||||
// Non-owner: use tenant_hint for pairing
|
||||
if (isOwner) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.TENANT_ID, slug);
|
||||
} else {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.TENANT_HINT, slug);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
|
||||
if (isConnected && wsRef.current) {
|
||||
const client = wsRef.current;
|
||||
store.setRole(client.role || "");
|
||||
store.setTenant(client.tenantId, client.tenantName, client.tenantSlug, client.crossTenant);
|
||||
store.setTenant(client.tenantId, client.tenantName, client.tenantSlug, client.isOwner);
|
||||
// Fetch tenant memberships asynchronously
|
||||
client.call<{ tenants: TenantMembership[] }>(Methods.TENANTS_MINE)
|
||||
.then((res) => {
|
||||
@@ -51,21 +51,21 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
|
||||
if (savedScope && tenants.some((t) => t.slug === savedScope)) {
|
||||
// Already scoped via localStorage — auto-select
|
||||
store.setTenantSelected(true);
|
||||
} else if (!client.crossTenant && tenants.length === 1) {
|
||||
// Non-admin with single tenant — auto-select
|
||||
} else if (!client.isOwner && tenants.length === 1) {
|
||||
// Non-owner with single tenant — auto-select
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.TENANT_ID, tenants[0]!.slug);
|
||||
store.setTenantSelected(true);
|
||||
} else if (!client.crossTenant && tenants.length === 0) {
|
||||
} else if (!client.isOwner && tenants.length === 0) {
|
||||
// No tenants — leave tenantSelected=false (blocked)
|
||||
} else if (client.crossTenant && !savedScope && tenants.length > 0) {
|
||||
// Cross-tenant admin without scope — auto-select first tenant
|
||||
} else if (client.isOwner && !savedScope && tenants.length > 0) {
|
||||
// Owner without scope — auto-select first tenant
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.TENANT_ID, tenants[0]!.slug);
|
||||
window.location.reload();
|
||||
return;
|
||||
} else if (client.crossTenant && !savedScope) {
|
||||
// Cross-tenant admin, no tenants available — use server default (MasterTenantID)
|
||||
} else if (client.isOwner && !savedScope) {
|
||||
// Owner, no tenants available — use server default (MasterTenantID)
|
||||
store.setTenantSelected(true);
|
||||
} else {
|
||||
store.setTenantSelected(true);
|
||||
|
||||
@@ -9,7 +9,7 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const tenantSelected = useAuthStore((s) => s.tenantSelected);
|
||||
const availableTenants = useAuthStore((s) => s.availableTenants);
|
||||
const isCrossTenant = useAuthStore((s) => s.isCrossTenant);
|
||||
const isOwner = useAuthStore((s) => s.isOwner);
|
||||
const location = useLocation();
|
||||
|
||||
// Not authenticated
|
||||
@@ -23,8 +23,8 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
return <Navigate to={ROUTES.SELECT_TENANT} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Connected, no tenants, not cross-tenant — blocked
|
||||
if (connected && !tenantSelected && availableTenants.length === 0 && !isCrossTenant) {
|
||||
// Connected, no tenants, not owner — blocked
|
||||
if (connected && !tenantSelected && availableTenants.length === 0 && !isOwner) {
|
||||
return <Navigate to={ROUTES.SELECT_TENANT} replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,29 +2,38 @@ import { Navigate } from "react-router";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
|
||||
/** Renders children only if user has admin role. Redirects to overview otherwise. */
|
||||
/** Check if role meets minimum level. Owner > Admin > Operator > Viewer. */
|
||||
function hasMinRole(role: string, minRole: string): boolean {
|
||||
const levels: Record<string, number> = { owner: 4, admin: 3, operator: 2, viewer: 1 };
|
||||
return (levels[role] ?? 0) >= (levels[minRole] ?? 0);
|
||||
}
|
||||
|
||||
/** Renders children only if user has admin role or higher. Redirects to overview otherwise. */
|
||||
export function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||
const role = useAuthStore((s) => s.role);
|
||||
if (role !== "admin") {
|
||||
if (!hasMinRole(role, "admin")) {
|
||||
return <Navigate to={ROUTES.OVERVIEW} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Renders children only if user has admin or operator role. */
|
||||
/** Renders children only if user has admin or operator role or higher. */
|
||||
export function RequireOperator({ children }: { children: React.ReactNode }) {
|
||||
const role = useAuthStore((s) => s.role);
|
||||
if (role !== "admin" && role !== "operator") {
|
||||
if (!hasMinRole(role, "operator")) {
|
||||
return <Navigate to={ROUTES.OVERVIEW} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Renders children only if user has cross-tenant access (system owner). */
|
||||
export function RequireCrossTenant({ children }: { children: React.ReactNode }) {
|
||||
const isCrossTenant = useAuthStore((s) => s.isCrossTenant);
|
||||
if (!isCrossTenant) {
|
||||
/** Renders children only if user is the system owner. */
|
||||
export function RequireOwner({ children }: { children: React.ReactNode }) {
|
||||
const isOwner = useAuthStore((s) => s.isOwner);
|
||||
if (!isOwner) {
|
||||
return <Navigate to={ROUTES.OVERVIEW} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** @deprecated Use RequireOwner instead. */
|
||||
export const RequireCrossTenant = RequireOwner;
|
||||
@@ -4,7 +4,7 @@ export function useTenants() {
|
||||
const tenantId = useAuthStore((s) => s.tenantId)
|
||||
const tenantName = useAuthStore((s) => s.tenantName)
|
||||
const tenantSlug = useAuthStore((s) => s.tenantSlug)
|
||||
const isCrossTenant = useAuthStore((s) => s.isCrossTenant)
|
||||
const isOwner = useAuthStore((s) => s.isOwner)
|
||||
const availableTenants = useAuthStore((s) => s.availableTenants)
|
||||
|
||||
return {
|
||||
@@ -12,8 +12,8 @@ export function useTenants() {
|
||||
currentTenantId: tenantId,
|
||||
currentTenantName: tenantName,
|
||||
currentTenantSlug: tenantSlug,
|
||||
isCrossTenant,
|
||||
isMultiTenant: availableTenants.length > 1 || isCrossTenant,
|
||||
isOwner,
|
||||
isMultiTenant: availableTenants.length > 1 || isOwner,
|
||||
currentTenant: availableTenants.find((t) => t.id === tenantId),
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,9 @@ interface Props {
|
||||
|
||||
export function ApiKeyCreateDialog({ open, onOpenChange, onCreate }: Props) {
|
||||
const { t } = useTranslation("api-keys");
|
||||
const { isCrossTenant, tenants, currentTenantId } = useTenants();
|
||||
const { isOwner, tenants, currentTenantId } = useTenants();
|
||||
|
||||
const defaultTenant = isCrossTenant
|
||||
const defaultTenant = isOwner
|
||||
? currentTenantId || SYSTEM_TENANT
|
||||
: SYSTEM_TENANT;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreate }: Props) {
|
||||
scopes,
|
||||
expires_in: expiryOption && expiryOption.seconds > 0 ? expiryOption.seconds : undefined,
|
||||
};
|
||||
if (isCrossTenant && tenantValue !== SYSTEM_TENANT) {
|
||||
if (isOwner && tenantValue !== SYSTEM_TENANT) {
|
||||
input.tenant_id = tenantValue;
|
||||
}
|
||||
await onCreate(input);
|
||||
@@ -105,7 +105,7 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreate }: Props) {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Name + Tenant row */}
|
||||
<div className={`grid gap-4 ${isCrossTenant ? "grid-cols-1 sm:grid-cols-2" : "grid-cols-1"}`}>
|
||||
<div className={`grid gap-4 ${isOwner ? "grid-cols-1 sm:grid-cols-2" : "grid-cols-1"}`}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="key-name">{t("form.name")}</Label>
|
||||
<Input
|
||||
@@ -118,7 +118,7 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreate }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCrossTenant && (
|
||||
{isOwner && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="key-tenant" className="flex items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ApiKeysPage() {
|
||||
const { t } = useTranslation("api-keys");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const { apiKeys, loading, refresh, createApiKey, revokeApiKey } = useApiKeys();
|
||||
const { isCrossTenant, tenants } = useTenants();
|
||||
const { isOwner, tenants } = useTenants();
|
||||
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && apiKeys.length === 0);
|
||||
@@ -124,7 +124,7 @@ export function ApiKeysPage() {
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.scopes")}</th>
|
||||
{isCrossTenant && (
|
||||
{isOwner && (
|
||||
<th className="px-4 py-3 text-left font-medium">Tenant</th>
|
||||
)}
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
|
||||
@@ -156,7 +156,7 @@ export function ApiKeysPage() {
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
{isCrossTenant && (
|
||||
{isOwner && (
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
|
||||
@@ -10,7 +10,7 @@ export function TenantSelectorPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const availableTenants = useAuthStore((s) => s.availableTenants);
|
||||
const isCrossTenant = useAuthStore((s) => s.isCrossTenant);
|
||||
const isOwner = useAuthStore((s) => s.isOwner);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname;
|
||||
@@ -27,8 +27,8 @@ export function TenantSelectorPage() {
|
||||
navigate(ROUTES.LOGIN, { replace: true });
|
||||
};
|
||||
|
||||
// No access state: not cross-tenant and no tenants
|
||||
if (!isCrossTenant && availableTenants.length === 0) {
|
||||
// No access state: not owner and no tenants
|
||||
if (!isOwner && availableTenants.length === 0) {
|
||||
return (
|
||||
<LoginLayout subtitle={t("noAccess")}>
|
||||
<div className="space-y-5 text-center">
|
||||
|
||||
@@ -54,7 +54,7 @@ export function MCPUserCredentialsDialog({
|
||||
const { users } = useTenantUsersList();
|
||||
|
||||
const canManageUsers =
|
||||
role === "admin" ||
|
||||
role === "admin" || role === "owner" ||
|
||||
currentTenant?.role === "owner" ||
|
||||
currentTenant?.role === "admin";
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ConnectedClientsCard({
|
||||
<td className="py-2 px-3">
|
||||
<Badge
|
||||
variant={
|
||||
c.role === "admin"
|
||||
c.role === "admin" || c.role === "owner"
|
||||
? "default"
|
||||
: c.role === "operator"
|
||||
? "secondary"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LOCAL_STORAGE_KEYS } from "@/lib/constants";
|
||||
import { clearSetupSkippedState } from "@/lib/setup-skip";
|
||||
import type { TenantMembership } from "@/types/tenant";
|
||||
|
||||
type UserRole = "admin" | "operator" | "viewer" | "";
|
||||
type UserRole = "owner" | "admin" | "operator" | "viewer" | "";
|
||||
|
||||
interface AuthState {
|
||||
token: string;
|
||||
@@ -15,7 +15,7 @@ interface AuthState {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
tenantSlug: string;
|
||||
isCrossTenant: boolean;
|
||||
isOwner: boolean;
|
||||
availableTenants: TenantMembership[];
|
||||
tenantSelected: boolean; // true after user picks a tenant (or auto-selected)
|
||||
|
||||
@@ -23,7 +23,7 @@ interface AuthState {
|
||||
setPairing: (senderID: string, userId: string) => void;
|
||||
setConnected: (connected: boolean, serverInfo?: { name?: string; version?: string }) => void;
|
||||
setRole: (role: UserRole) => void;
|
||||
setTenant: (id: string, name: string, slug: string, isCrossTenant: boolean) => void;
|
||||
setTenant: (id: string, name: string, slug: string, isOwner: boolean) => void;
|
||||
setAvailableTenants: (tenants: TenantMembership[]) => void;
|
||||
setTenantSelected: (selected: boolean) => void;
|
||||
logout: () => void;
|
||||
@@ -39,7 +39,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isCrossTenant: false,
|
||||
isOwner: false,
|
||||
availableTenants: [],
|
||||
tenantSelected: !!localStorage.getItem(LOCAL_STORAGE_KEYS.TENANT_ID),
|
||||
|
||||
@@ -63,8 +63,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
set({ role });
|
||||
},
|
||||
|
||||
setTenant: (id, name, slug, isCrossTenant) => {
|
||||
set({ tenantId: id, tenantName: name, tenantSlug: slug, isCrossTenant });
|
||||
setTenant: (id, name, slug, isOwner) => {
|
||||
set({ tenantId: id, tenantName: name, tenantSlug: slug, isOwner });
|
||||
},
|
||||
|
||||
setAvailableTenants: (tenants) => {
|
||||
@@ -84,7 +84,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
clearSetupSkippedState();
|
||||
set({
|
||||
token: "", userId: "", senderID: "", connected: false, role: "", serverInfo: null,
|
||||
tenantId: "", tenantName: "", tenantSlug: "", isCrossTenant: false, availableTenants: [],
|
||||
tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, availableTenants: [],
|
||||
tenantSelected: false,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user