Changeset 0.24.2 (#158)
This commit is contained in:
@@ -409,7 +409,7 @@ func (p *OIDCProvider) findOrCreateOIDCGroup(ctx context.Context, name, createdB
|
||||
Description: "Synced from OIDC",
|
||||
Scope: "global",
|
||||
Source: "oidc",
|
||||
CreatedBy: createdBy,
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
if err := stores.Groups.Create(ctx, g); err != nil {
|
||||
log.Printf("[auth/oidc] warn: could not create group %s: %v", name, err)
|
||||
|
||||
164
server/auth/permissions.go
Normal file
164
server/auth/permissions.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in
|
||||
// migration 020. Every authenticated user receives its permissions without an
|
||||
// explicit membership row.
|
||||
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// Permission constants — domain.action convention.
|
||||
const (
|
||||
PermModelUse = "model.use" // use models for completion
|
||||
PermModelSelectAny = "model.select_any" // use any enabled model (vs. group allowlist)
|
||||
PermKBRead = "kb.read" // search KBs through personas
|
||||
PermKBWrite = "kb.write" // upload/delete KB documents
|
||||
PermKBCreate = "kb.create" // create new knowledge bases
|
||||
PermChannelCreate = "channel.create" // create group/channel conversations
|
||||
PermChannelInvite = "channel.invite" // invite users to channels
|
||||
PermPersonaCreate = "persona.create" // create new personas
|
||||
PermPersonaManage = "persona.manage" // edit/delete team personas
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions (v0.25.0)
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
)
|
||||
|
||||
// AllPermissions is the complete set of valid permission strings.
|
||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
||||
var AllPermissions = []string{
|
||||
PermModelUse,
|
||||
PermModelSelectAny,
|
||||
PermKBRead,
|
||||
PermKBWrite,
|
||||
PermKBCreate,
|
||||
PermChannelCreate,
|
||||
PermChannelInvite,
|
||||
PermPersonaCreate,
|
||||
PermPersonaManage,
|
||||
PermWorkflowCreate,
|
||||
PermAdminView,
|
||||
PermTokenUnlimited,
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
// ResolvePermissions returns the effective permission set for a user.
|
||||
// Always includes the Everyone group regardless of membership.
|
||||
// Callers should short-circuit for role=admin before calling this.
|
||||
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
perms := make(map[string]bool)
|
||||
|
||||
// Always apply Everyone group — no membership row required.
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
for _, p := range everyone.Permissions {
|
||||
perms[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Union all explicit group memberships.
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return perms, err
|
||||
}
|
||||
for _, g := range groups {
|
||||
for _, p := range g.Permissions {
|
||||
perms[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ── Token Budget ────────────────────────────
|
||||
|
||||
// TokenBudget holds the resolved ceiling for a user and the time window it covers.
|
||||
type TokenBudget struct {
|
||||
Limit int64
|
||||
Period string // "daily" or "monthly"
|
||||
}
|
||||
|
||||
// ResolveTokenBudget returns the most restrictive token budget across all of the
|
||||
// user's groups. Returns nil if no group has a budget set (unrestricted).
|
||||
// Callers should skip budget enforcement when providerScope == "personal" (BYOK).
|
||||
func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
isNewDay := now.Hour() < 1
|
||||
_ = isNewDay // used by callers for cache invalidation hints if needed
|
||||
|
||||
var daily, monthly *int64
|
||||
for _, g := range groups {
|
||||
if g.TokenBudgetDaily != nil {
|
||||
if daily == nil || *g.TokenBudgetDaily < *daily {
|
||||
daily = g.TokenBudgetDaily
|
||||
}
|
||||
}
|
||||
if g.TokenBudgetMonthly != nil {
|
||||
if monthly == nil || *g.TokenBudgetMonthly < *monthly {
|
||||
monthly = g.TokenBudgetMonthly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the most restrictive window. Daily wins when both are set
|
||||
// and daily is the binding constraint.
|
||||
if daily != nil {
|
||||
return &TokenBudget{Limit: *daily, Period: "daily"}, nil
|
||||
}
|
||||
if monthly != nil {
|
||||
return &TokenBudget{Limit: *monthly, Period: "monthly"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── Model Allowlist ─────────────────────────
|
||||
|
||||
// ResolveModelAllowlist returns the union of allowed model IDs across all of the
|
||||
// user's groups. Returns nil if the user is unrestricted (any group has a nil
|
||||
// allowlist, which means "no restriction").
|
||||
func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If any group has nil AllowedModels, the user is unrestricted.
|
||||
for _, g := range groups {
|
||||
if g.AllowedModels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// All groups have explicit allowlists — union them.
|
||||
allowed := make(map[string]bool)
|
||||
for _, g := range groups {
|
||||
for _, m := range g.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
// No groups at all → fall through to Everyone group check.
|
||||
if len(groups) == 0 {
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
if everyone.AllowedModels == nil {
|
||||
return nil, nil // Everyone has no restriction
|
||||
}
|
||||
for _, m := range everyone.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return nil, nil // empty allowlists across all groups = unrestricted
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
36
server/database/migrations/020_permissions.sql
Normal file
36
server/database/migrations/020_permissions.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- 020_permissions.sql
|
||||
-- Fine-grained permissions on groups (v0.24.2)
|
||||
|
||||
-- Extend the groups.source CHECK to include 'system' (system-seeded groups).
|
||||
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_source_check;
|
||||
ALTER TABLE groups ADD CONSTRAINT groups_source_check
|
||||
CHECK (source IN ('manual', 'oidc', 'system'));
|
||||
|
||||
-- created_by is meaningless for system-seeded groups; allow NULL.
|
||||
ALTER TABLE groups ALTER COLUMN created_by DROP NOT NULL;
|
||||
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS token_budget_daily BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS token_budget_monthly BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS allowed_models JSONB;
|
||||
|
||||
COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to group members';
|
||||
COMMENT ON COLUMN groups.token_budget_daily IS 'Daily token ceiling for members (NULL = unlimited)';
|
||||
COMMENT ON COLUMN groups.token_budget_monthly IS 'Monthly token ceiling for members (NULL = unlimited)';
|
||||
COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group may use. NULL = unrestricted.';
|
||||
|
||||
-- Seed the Everyone group with a stable well-known ID.
|
||||
-- All authenticated users receive its permissions implicitly (no membership row needed).
|
||||
-- source='system' prevents deletion via the store guard.
|
||||
INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'Everyone',
|
||||
'Implicit group — all authenticated users receive these permissions.',
|
||||
'global',
|
||||
NULL,
|
||||
'system',
|
||||
'["model.use","kb.read","channel.create"]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
25
server/database/migrations/sqlite/020_permissions.sql
Normal file
25
server/database/migrations/sqlite/020_permissions.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Chat Switchboard — 020 Permissions (SQLite) (v0.24.2)
|
||||
|
||||
-- SQLite cannot drop NOT NULL inline; recreating the constraint isn't needed
|
||||
-- since SQLite doesn't enforce CHECK constraints strictly and the column
|
||||
-- already accepts NULL in practice. The store layer guards source='system'.
|
||||
|
||||
ALTER TABLE groups ADD COLUMN permissions TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE groups ADD COLUMN token_budget_daily INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN token_budget_monthly INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN allowed_models TEXT;
|
||||
|
||||
-- Seed the Everyone group with a stable well-known ID.
|
||||
INSERT OR IGNORE INTO groups
|
||||
(id, name, description, scope, created_by, source, permissions, created_at, updated_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'Everyone',
|
||||
'Implicit group — all authenticated users receive these permissions.',
|
||||
'global',
|
||||
NULL,
|
||||
'system',
|
||||
'["model.use","kb.read","channel.create"]',
|
||||
datetime('now'),
|
||||
datetime('now')
|
||||
);
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -419,7 +419,7 @@ func main() {
|
||||
// Channels
|
||||
channels := handlers.NewChannelHandler()
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
@@ -498,7 +498,7 @@ func main() {
|
||||
// Channel participants (v0.23.0 — ICD §3.7)
|
||||
partH := handlers.NewParticipantHandler(stores)
|
||||
protected.GET("/channels/:id/participants", partH.List)
|
||||
protected.POST("/channels/:id/participants", partH.Add)
|
||||
protected.POST("/channels/:id/participants", middleware.RequirePermission(auth.PermChannelInvite, stores), partH.Add)
|
||||
protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
|
||||
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
|
||||
|
||||
@@ -573,9 +573,9 @@ func main() {
|
||||
// Personas
|
||||
personas := handlers.NewPersonaHandler(stores)
|
||||
protected.GET("/personas", personas.ListUserPersonas)
|
||||
protected.POST("/personas", personas.CreateUserPersona)
|
||||
protected.PUT("/personas/:id", personas.UpdateUserPersona)
|
||||
protected.DELETE("/personas/:id", personas.DeleteUserPersona)
|
||||
protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona)
|
||||
protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
|
||||
protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
|
||||
protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
|
||||
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
@@ -692,12 +692,12 @@ func main() {
|
||||
|
||||
// Knowledge Bases (RAG — v0.14.0)
|
||||
kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder)
|
||||
protected.POST("/knowledge-bases", kbH.CreateKB)
|
||||
protected.POST("/knowledge-bases", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB)
|
||||
protected.GET("/knowledge-bases", kbH.ListKBs)
|
||||
protected.GET("/knowledge-bases/:id", kbH.GetKB)
|
||||
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
|
||||
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB)
|
||||
protected.POST("/knowledge-bases/:id/documents", kbH.UploadDocument)
|
||||
protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UploadDocument)
|
||||
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
|
||||
protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
|
||||
protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument)
|
||||
@@ -859,6 +859,10 @@ func main() {
|
||||
admin.POST("/groups/:id/members", groupAdm.AddMember)
|
||||
admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)
|
||||
|
||||
// Permissions (v0.24.2)
|
||||
admin.GET("/permissions", groupAdm.ListPermissions)
|
||||
admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)
|
||||
|
||||
// Resource Grants (admin — v0.16.0)
|
||||
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
|
||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||
|
||||
60
server/middleware/permissions.go
Normal file
60
server/middleware/permissions.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
const permCacheKey = "resolved_permissions"
|
||||
|
||||
// RequirePermission returns middleware that enforces a named permission.
|
||||
// Admin users bypass the check entirely. Permission resolution is cached in
|
||||
// the request context — computed at most once per request regardless of how
|
||||
// many RequirePermission middlewares are chained.
|
||||
func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role == "admin" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[perm] {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "permission required: " + perm,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAndCachePerms loads the user's effective permissions once per request.
|
||||
func resolveAndCachePerms(c *gin.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
if cached, exists := c.Get(permCacheKey); exists {
|
||||
return cached.(map[string]bool), nil
|
||||
}
|
||||
perms, err := auth.ResolvePermissions(c.Request.Context(), stores, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Set(permCacheKey, perms)
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// GetResolvedPermissions returns the cached permission set for the current
|
||||
// request. Returns nil if not yet resolved (i.e. RequirePermission was not
|
||||
// earlier in the chain). Callers in handlers can use this for conditional
|
||||
// logic without triggering an extra DB round-trip.
|
||||
func GetResolvedPermissions(c *gin.Context) map[string]bool {
|
||||
if cached, exists := c.Get(permCacheKey); exists {
|
||||
return cached.(map[string]bool)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -859,13 +859,30 @@ const (
|
||||
// Group is an access-control list. Decouples resource visibility from teams.
|
||||
type Group struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Scope string `json:"scope" db:"scope"` // global, team
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
Source string `json:"source" db:"source"` // manual (default), oidc
|
||||
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Scope string `json:"scope" db:"scope"` // global, team
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
|
||||
Source string `json:"source" db:"source"` // manual (default), oidc, system
|
||||
Permissions []string `json:"permissions" db:"permissions"`
|
||||
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty" db:"token_budget_daily"`
|
||||
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty" db:"token_budget_monthly"`
|
||||
AllowedModels []string `json:"allowed_models,omitempty" db:"allowed_models"` // nil = unrestricted
|
||||
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
|
||||
}
|
||||
|
||||
// GroupPatch holds optional fields for updating a group.
|
||||
type GroupPatch struct {
|
||||
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"` // &[]string{} = restrict to none; nil = no change
|
||||
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
|
||||
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
|
||||
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
|
||||
}
|
||||
|
||||
// GroupMember links a user to a group.
|
||||
|
||||
@@ -476,7 +476,7 @@ type KnowledgeBaseStore interface {
|
||||
type GroupStore interface {
|
||||
Create(ctx context.Context, g *models.Group) error
|
||||
GetByID(ctx context.Context, id string) (*models.Group, error)
|
||||
Update(ctx context.Context, id string, name, description *string) error
|
||||
Update(ctx context.Context, id string, patch models.GroupPatch) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Scoped listing
|
||||
|
||||
@@ -3,6 +3,8 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -18,41 +20,79 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
if g.Source == "" {
|
||||
g.Source = "manual"
|
||||
}
|
||||
if g.Permissions == nil {
|
||||
g.Permissions = []string{}
|
||||
}
|
||||
permsJSON, err := json.Marshal(g.Permissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal permissions: %w", err)
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, description, scope, team_id, created_by, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO groups (name, description, scope, team_id, created_by, source, permissions)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy, g.Source,
|
||||
models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, permsJSON,
|
||||
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
row := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g WHERE g.id = $1`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
return &g, nil
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'::jsonb),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models
|
||||
FROM groups g WHERE g.id = $1`, id)
|
||||
return scanGroup(row)
|
||||
}
|
||||
|
||||
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
|
||||
b := NewUpdate("groups")
|
||||
if name != nil {
|
||||
b.Set("name", *name)
|
||||
// Update applies a patch to a group.
|
||||
// The Everyone group (source=system) allows permission/budget edits but not
|
||||
// name, description, or structural changes — those fields are silently ignored.
|
||||
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
||||
var source string
|
||||
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if description != nil {
|
||||
b.Set("description", *description)
|
||||
|
||||
b := NewUpdate("groups")
|
||||
if source != "system" {
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
b.Set("description", *patch.Description)
|
||||
}
|
||||
}
|
||||
if patch.Permissions != nil {
|
||||
permsJSON, err := json.Marshal(*patch.Permissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal permissions: %w", err)
|
||||
}
|
||||
b.Set("permissions", permsJSON)
|
||||
}
|
||||
if patch.ClearBudgetDaily {
|
||||
b.SetNull("token_budget_daily")
|
||||
} else if patch.TokenBudgetDaily != nil {
|
||||
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
|
||||
}
|
||||
if patch.ClearBudgetMonthly {
|
||||
b.SetNull("token_budget_monthly")
|
||||
} else if patch.TokenBudgetMonthly != nil {
|
||||
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
|
||||
}
|
||||
if patch.ClearAllowedModels {
|
||||
b.SetNull("allowed_models")
|
||||
} else if patch.AllowedModels != nil {
|
||||
modelsJSON, err := json.Marshal(*patch.AllowedModels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal allowed_models: %w", err)
|
||||
}
|
||||
b.Set("allowed_models", modelsJSON)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
@@ -68,7 +108,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a group. Returns an error if source=system.
|
||||
func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
||||
var source string
|
||||
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
if source == "system" {
|
||||
return fmt.Errorf("system groups cannot be deleted")
|
||||
}
|
||||
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -81,33 +132,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
||||
|
||||
// ── Scoped Listing ──────────────────────────
|
||||
|
||||
const groupSelectCols = `
|
||||
g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'::jsonb),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models`
|
||||
|
||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
return queryGroups(ctx, `SELECT`+groupSelectCols+`
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
return queryGroups(ctx, `SELECT`+groupSelectCols+`
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = $1
|
||||
ORDER BY g.name`, teamID)
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
return queryGroups(ctx, `SELECT`+groupSelectCols+`
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
@@ -191,6 +240,40 @@ func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]stri
|
||||
|
||||
// ── Scanners ────────────────────────────────
|
||||
|
||||
type groupScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGroup(row groupScanner) (*models.Group, error) {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
var createdBy sql.NullString
|
||||
var permsJSON []byte
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedJSON []byte
|
||||
|
||||
if err := row.Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
g.Permissions = unmarshalStringSlice(permsJSON)
|
||||
if budgetDaily.Valid {
|
||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
||||
}
|
||||
if budgetMonthly.Valid {
|
||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
||||
}
|
||||
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
|
||||
g.AllowedModels = unmarshalStringSlice(allowedJSON)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
@@ -202,12 +285,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
for rows.Next() {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source); err != nil {
|
||||
var createdBy sql.NullString
|
||||
var permsJSON []byte
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedJSON []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
g.Permissions = unmarshalStringSlice(permsJSON)
|
||||
if budgetDaily.Valid {
|
||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
||||
}
|
||||
if budgetMonthly.Valid {
|
||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
||||
}
|
||||
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
|
||||
g.AllowedModels = unmarshalStringSlice(allowedJSON)
|
||||
}
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func unmarshalStringSlice(data []byte) []string {
|
||||
if len(data) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
var s []string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
// SetNull adds a col = NULL pair to the UPDATE.
|
||||
func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder {
|
||||
b.argIdx++
|
||||
b.sets = append(b.sets, fmt.Sprintf("%s = NULL", col))
|
||||
return b
|
||||
}
|
||||
|
||||
// SetJSON adds a JSONB column from a map.
|
||||
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
|
||||
data, err := json.Marshal(val)
|
||||
|
||||
@@ -3,6 +3,8 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -24,11 +26,18 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
if g.Source == "" {
|
||||
g.Source = "manual"
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
if g.Permissions == nil {
|
||||
g.Permissions = []string{}
|
||||
}
|
||||
permsJSON, err := json.Marshal(g.Permissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal permissions: %w", err)
|
||||
}
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, permissions, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.ID, g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy, g.Source,
|
||||
models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, string(permsJSON),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
@@ -37,29 +46,85 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
var createdBy sql.NullString
|
||||
var permsStr sql.NullString
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedStr sql.NullString
|
||||
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual')
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models
|
||||
FROM groups g WHERE g.id = ?`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
g.Permissions = unmarshalStringSlice(permsStr.String)
|
||||
if budgetDaily.Valid {
|
||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
||||
}
|
||||
if budgetMonthly.Valid {
|
||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
||||
}
|
||||
if allowedStr.Valid && allowedStr.String != "" {
|
||||
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
|
||||
b := NewUpdate("groups")
|
||||
if name != nil {
|
||||
b.Set("name", *name)
|
||||
// Update applies a patch to a group.
|
||||
// system groups allow permission/budget edits but not structural field changes.
|
||||
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
||||
var source string
|
||||
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if description != nil {
|
||||
b.Set("description", *description)
|
||||
|
||||
b := NewUpdate("groups")
|
||||
if source != "system" {
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
b.Set("description", *patch.Description)
|
||||
}
|
||||
}
|
||||
if patch.Permissions != nil {
|
||||
permsJSON, err := json.Marshal(*patch.Permissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal permissions: %w", err)
|
||||
}
|
||||
b.Set("permissions", string(permsJSON))
|
||||
}
|
||||
if patch.ClearBudgetDaily {
|
||||
b.SetNull("token_budget_daily")
|
||||
} else if patch.TokenBudgetDaily != nil {
|
||||
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
|
||||
}
|
||||
if patch.ClearBudgetMonthly {
|
||||
b.SetNull("token_budget_monthly")
|
||||
} else if patch.TokenBudgetMonthly != nil {
|
||||
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
|
||||
}
|
||||
if patch.ClearAllowedModels {
|
||||
b.SetNull("allowed_models")
|
||||
} else if patch.AllowedModels != nil {
|
||||
modelsJSON, err := json.Marshal(*patch.AllowedModels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal allowed_models: %w", err)
|
||||
}
|
||||
b.Set("allowed_models", string(modelsJSON))
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
@@ -75,7 +140,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a group. Returns an error if source=system.
|
||||
func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
||||
var source string
|
||||
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
if source == "system" {
|
||||
return fmt.Errorf("system groups cannot be deleted")
|
||||
}
|
||||
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -88,33 +164,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
||||
|
||||
// ── Scoped Listing ──────────────────────────
|
||||
|
||||
const groupSelectCols = `
|
||||
g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models`
|
||||
|
||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
return queryGroups(ctx, `SELECT `+groupSelectCols+`
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
return queryGroups(ctx, `SELECT `+groupSelectCols+`
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = ?
|
||||
ORDER BY g.name`, teamID)
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
return queryGroups(ctx, `SELECT `+groupSelectCols+`
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
@@ -209,12 +283,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
for rows.Next() {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source); err != nil {
|
||||
var createdBy sql.NullString
|
||||
var permsStr sql.NullString
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedStr sql.NullString
|
||||
|
||||
if err := rows.Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
g.Permissions = unmarshalStringSlice(permsStr.String)
|
||||
if budgetDaily.Valid {
|
||||
g.TokenBudgetDaily = &budgetDaily.Int64
|
||||
}
|
||||
if budgetMonthly.Valid {
|
||||
g.TokenBudgetMonthly = &budgetMonthly.Int64
|
||||
}
|
||||
if allowedStr.Valid && allowedStr.String != "" {
|
||||
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
|
||||
}
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func unmarshalStringSlice(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -97,6 +97,11 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder {
|
||||
b.sets = append(b.sets, col+" = NULL")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
|
||||
data, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user