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:
viettranx committed 2026-03-24 20:40:59 +07:00
1 parent 7143dcf32c
commit 7aa47d1baf
59 files changed
+365 -260

No files matched your search

+3 -3
View File
@@ -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
}
}
+2 -3
View File
@@ -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
+3 -3
View File
@@ -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++
+2 -1
View File
@@ -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
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -3
View File
@@ -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
+1 -1
View File
@@ -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()
}
+3
View File
@@ -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
}
+6 -4
View File
@@ -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)
}
-3
View File
@@ -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
+5 -6
View File
@@ -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 {
+14 -17
View File
@@ -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).
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+7 -7
View File
@@ -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,
+9 -9
View File
@@ -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
View File
@@ -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)
}
+2 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
}
+8
View File
@@ -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
}
+8 -6
View File
@@ -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:
+7 -7
View File
@@ -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
}
+1 -1
View File
@@ -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)
+8
View File
@@ -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
}
+3
View File
@@ -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:
+1 -1
View File
@@ -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))
}
+1
View File
@@ -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
+2
View File
@@ -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)
}
+14 -1
View File
@@ -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)
+3 -2
View File
@@ -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)
}
}()
}
+22
View File
@@ -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)
}
+1 -8
View File
@@ -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")
+23
View File
@@ -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
View File
@@ -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 {
+16
View File
@@ -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)
}
+1
View File
@@ -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
}
+1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+3 -6
View File
@@ -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)
+1 -4
View File
@@ -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
}
+3 -3
View File
@@ -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) {
+4 -4
View File
@@ -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} />
+5 -5
View File
@@ -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 />;
}
+17 -8
View File
@@ -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;
+3 -3
View File
@@ -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" />
+3 -3
View File
@@ -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" />
+3 -3
View File
@@ -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"
+7 -7
View File
@@ -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,
});
},