Changeset 0.24.2 (#158)

This commit is contained in:
2026-03-07 19:58:43 +00:00
parent b6cc4df6e7
commit ea082e2016
24 changed files with 954 additions and 119 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -56,6 +57,23 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
// Include admin default model so frontend can use it in resolution chain
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
// Model allowlist filtering (v0.24.2) — non-admin users with group-level
// model restrictions only see permitted models. Persona entries whose
// underlying model is disallowed are also filtered.
role, _ := c.Get("role")
if role != "admin" {
allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID)
if err == nil && allowlist != nil {
filtered := make([]models.UserModel, 0, len(userModels))
for _, m := range userModels {
if allowlist[m.ModelID] {
filtered = append(filtered, m)
}
}
userModels = filtered
}
}
c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel})
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
@@ -625,6 +626,44 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
}
// ── Permission pre-flight ─────────────────────────────────────────────
// Budget enforcement and model allowlist only apply when the request draws
// from a team or global provider. BYOK (personal scope) is the user's money
// — no ceiling applies.
role, _ := c.Get("role")
if role != "admin" && providerScope != "personal" {
// Token budget
if h.stores.Usage != nil {
budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID)
if err == nil && budget != nil {
var opts store.UsageQueryOptions
now := time.Now()
if budget.Period == "daily" {
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
opts.Since = &dayStart
} else {
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
opts.Since = &monthStart
}
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
if err == nil && totals != nil {
used := int64(totals.InputTokens + totals.OutputTokens)
if used >= budget.Limit {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "token budget exceeded"})
return
}
}
}
}
// Model allowlist
allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID)
if err == nil && allowlist != nil && !allowlist[model] {
c.JSON(http.StatusForbidden, gin.H{"error": "model not permitted for your account: " + model})
return
}
}
// Determine streaming
stream := true // default
if req.Stream != nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
@@ -22,8 +23,15 @@ type createGroupRequest struct {
}
type updateGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions *[]string `json:"permissions,omitempty"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
AllowedModels *[]string `json:"allowed_models,omitempty"`
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
}
type addGroupMemberRequest struct {
@@ -85,7 +93,7 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
Description: req.Description,
Scope: req.Scope,
TeamID: req.TeamID,
CreatedBy: actorID,
CreatedBy: &actorID,
}
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
@@ -132,12 +140,34 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
return
}
if req.Name == nil && req.Description == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
patch := models.GroupPatch{
Name: req.Name,
Description: req.Description,
Permissions: req.Permissions,
TokenBudgetDaily: req.TokenBudgetDaily,
TokenBudgetMonthly: req.TokenBudgetMonthly,
AllowedModels: req.AllowedModels,
ClearBudgetDaily: req.ClearBudgetDaily,
ClearBudgetMonthly: req.ClearBudgetMonthly,
ClearAllowedModels: req.ClearAllowedModels,
}
err := h.stores.Groups.Update(c.Request.Context(), id, req.Name, req.Description)
// Validate permissions if provided
if patch.Permissions != nil {
valid := auth.AllPermissions
validSet := make(map[string]bool, len(valid))
for _, p := range valid {
validSet[p] = true
}
for _, p := range *patch.Permissions {
if !validSet[p] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown permission: " + p})
return
}
}
}
err := h.stores.Groups.Update(c.Request.Context(), id, patch)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
@@ -166,6 +196,11 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) {
return
}
if err != nil {
// Store layer returns a plain error for system groups.
if err.Error() == "system groups cannot be deleted" {
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
@@ -382,3 +417,28 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "grant.delete", resourceType, resourceID, nil)
}
// ListPermissions returns all valid permission strings.
// GET /api/v1/admin/permissions
func (h *GroupHandler) ListPermissions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"permissions": auth.AllPermissions})
}
// GetUserPermissions returns the effective permissions for a given user.
// GET /api/v1/admin/users/:id/permissions
func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
userID := c.Param("id")
perms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return
}
// Convert map to sorted slice for stable output
list := make([]string, 0, len(perms))
for p := range perms {
list = append(list, p)
}
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID})
}