feat(skills): add per-tenant skill config HTTP endpoints (#428)

Add PUT/DELETE /v1/skills/{id}/tenant-config routes for managing
per-tenant skill visibility overrides. Enrich GET /v1/skills list
with tenant_enabled field when tenant-scoped. Add ListAll method
to SkillTenantConfigStore interface + PG implementation.
This commit is contained in:
viettranx committed 2026-03-25 09:58:56 +07:00
1 parent db0f62215a
commit c5164255ef
4 files changed
+129 -10

No files matched your search

+1 -1
View File
@@ -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)
}
}
}
+105 -9
View File
@@ -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"})
}
+21
View File
@@ -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)
+2
View File
@@ -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).