diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 1c285aa5..e92d9161 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -33,7 +33,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if pgSkills, ok := stores.Skills.(*pg.PGSkillStore); ok { dirs := pgSkills.Dirs() if len(dirs) > 0 { - skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, msgBus) + skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs) } } } diff --git a/internal/http/skills.go b/internal/http/skills.go index 87b14427..ee943748 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -24,16 +24,17 @@ const maxSkillUploadSize = 20 << 20 // 20 MB // SkillsHandler handles skill management HTTP endpoints. type SkillsHandler struct { - skills *pg.PGSkillStore - baseDir string // filesystem base for skill content (skills-store/) — master tenant - dataDir string // parent data dir for tenant-scoped skill paths - bundledDir string // original bundled skills dir (fallback for broken managed copies) - msgBus *bus.MessageBus + skills *pg.PGSkillStore + baseDir string // filesystem base for skill content (skills-store/) — master tenant + dataDir string // parent data dir for tenant-scoped skill paths + bundledDir string // original bundled skills dir (fallback for broken managed copies) + msgBus *bus.MessageBus + tenantCfgStore store.SkillTenantConfigStore } // NewSkillsHandler creates a handler for skill management endpoints. -func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus) *SkillsHandler { - return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus} +func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore) *SkillsHandler { + return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore} } // tenantSkillsDir returns the skills-store directory scoped to the requesting tenant. @@ -77,6 +78,8 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/skills/install-dep", h.adminMiddleware(h.handleInstallDep)) mux.HandleFunc("GET /v1/skills/runtimes", h.adminMiddleware(h.handleRuntimes)) mux.HandleFunc("POST /v1/skills/{id}/toggle", h.adminMiddleware(h.handleToggle)) + mux.HandleFunc("PUT /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleSetTenantConfig)) + mux.HandleFunc("DELETE /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleDeleteTenantConfig)) } func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { @@ -109,8 +112,35 @@ func (h *SkillsHandler) requireMasterTenant(w http.ResponseWriter, r *http.Reque } func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) { - skills := h.skills.ListSkills(r.Context()) - writeJSON(w, http.StatusOK, map[string]any{"skills": skills}) + skillList := h.skills.ListSkills(r.Context()) + + // Merge per-tenant overrides into response when tenant-scoped + tid := store.TenantIDFromContext(r.Context()) + if tid != uuid.Nil && h.tenantCfgStore != nil { + overrides, err := h.tenantCfgStore.ListAll(r.Context(), tid) + if err != nil { + slog.Warn("skill tenant config list failed", "tenant", tid, "error", err) + } + if err == nil && len(overrides) > 0 { + type skillWithTenant struct { + store.SkillInfo + TenantEnabled *bool `json:"tenant_enabled"` + } + enriched := make([]skillWithTenant, len(skillList)) + for i, sk := range skillList { + enriched[i] = skillWithTenant{SkillInfo: sk} + if skID, err := uuid.Parse(sk.ID); err == nil { + if enabled, ok := overrides[skID]; ok { + enriched[i].TenantEnabled = &enabled + } + } + } + writeJSON(w, http.StatusOK, map[string]any{"skills": enriched}) + return + } + } + + writeJSON(w, http.StatusOK, map[string]any{"skills": skillList}) } func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) { @@ -488,3 +518,69 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { emitAudit(h.msgBus, r, "skill.toggled", "skill", idStr) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": body.Enabled, "status": newStatus}) } + +// handleSetTenantConfig sets a per-tenant override for a skill. +func (h *SkillsHandler) handleSetTenantConfig(w http.ResponseWriter, r *http.Request) { + if h.tenantCfgStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"}) + return + } + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"}) + return + } + + idStr := r.PathValue("id") + skillID, err := uuid.Parse(idStr) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")}) + return + } + + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if err := h.tenantCfgStore.Set(r.Context(), tid, skillID, body.Enabled); err != nil { + slog.Warn("set tenant skill config failed", "skill", idStr, "tenant", tid, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "skill tenant config")}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// handleDeleteTenantConfig removes a per-tenant override for a skill (reverts to default). +func (h *SkillsHandler) handleDeleteTenantConfig(w http.ResponseWriter, r *http.Request) { + if h.tenantCfgStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"}) + return + } + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"}) + return + } + + idStr := r.PathValue("id") + skillID, err := uuid.Parse(idStr) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")}) + return + } + + if err := h.tenantCfgStore.Delete(r.Context(), tid, skillID); err != nil { + slog.Warn("delete tenant skill config failed", "skill", idStr, "tenant", tid, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "skill tenant config")}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/store/pg/tenant_configs.go b/internal/store/pg/tenant_configs.go index 7ae29f34..7a5ba8e3 100644 --- a/internal/store/pg/tenant_configs.go +++ b/internal/store/pg/tenant_configs.go @@ -107,6 +107,27 @@ func (s *PGSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, ten return ids, rows.Err() } +func (s *PGSkillTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT skill_id, enabled FROM skill_tenant_configs WHERE tenant_id = $1`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make(map[uuid.UUID]bool) + for rows.Next() { + var id uuid.UUID + var enabled bool + if err := rows.Scan(&id, &enabled); err != nil { + return nil, err + } + result[id] = enabled + } + return result, rows.Err() +} + func (s *PGSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error { _, err := s.db.ExecContext(ctx, `INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at) diff --git a/internal/store/tenant_config_store.go b/internal/store/tenant_config_store.go index 0a78180a..0d7a2dd9 100644 --- a/internal/store/tenant_config_store.go +++ b/internal/store/tenant_config_store.go @@ -36,6 +36,8 @@ type SkillTenantConfig struct { type SkillTenantConfigStore interface { // ListDisabledSkillIDs returns skill IDs disabled for a tenant. ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) + // ListAll returns all tenant overrides (skillID → enabled) for a tenant. + ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error) // Set creates or updates a tenant skill config. Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error // Delete removes a tenant skill config (reverts to default).