Changeset 0.29.0 (#195)
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -152,68 +151,44 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
sets := []string{}
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
addArg := func(col string, val interface{}) {
|
||||
if database.IsSQLite() {
|
||||
sets = append(sets, col+" = ?")
|
||||
} else {
|
||||
sets = append(sets, col+" = $"+strconv.Itoa(argN))
|
||||
}
|
||||
args = append(args, val)
|
||||
argN++
|
||||
}
|
||||
|
||||
// Build fields map for store.Update (simple scalar fields)
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
addArg("name", *req.Name)
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
addArg("description", *req.Description)
|
||||
fields["description"] = *req.Description
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addArg("is_active", *req.IsActive)
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if database.IsSQLite() {
|
||||
// SQLite: json_patch for merge
|
||||
sets = append(sets, "settings = json_patch(COALESCE(settings, '{}'), ?)")
|
||||
} else {
|
||||
sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||
}
|
||||
args = append(args, *req.Settings)
|
||||
argN++
|
||||
fields["is_active"] = *req.IsActive
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
hasFields := len(fields) > 0
|
||||
hasSettings := req.Settings != nil
|
||||
|
||||
if !hasFields && !hasSettings {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
var whereClause string
|
||||
if database.IsSQLite() {
|
||||
whereClause = " WHERE id = ?"
|
||||
} else {
|
||||
whereClause = " WHERE id = $" + strconv.Itoa(argN)
|
||||
}
|
||||
args = append(args, teamID)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
query := "UPDATE teams SET " + strings.Join(sets, ", ") + whereClause
|
||||
|
||||
res, err := database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
||||
if hasFields {
|
||||
if err := h.stores.Teams.Update(ctx, teamID, fields); err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
|
||||
if hasSettings {
|
||||
if err := h.stores.Teams.MergeSettings(ctx, teamID, *req.Settings); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
@@ -266,6 +241,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) {
|
||||
|
||||
func (h *TeamHandler) AddMember(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req addMemberRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -274,25 +250,20 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Verify team exists
|
||||
var exists bool
|
||||
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`), teamID).Scan(&exists)
|
||||
exists, _ := h.stores.Teams.Exists(ctx, teamID)
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`), req.UserID).Scan(&exists)
|
||||
if !exists {
|
||||
userExists, _ := h.stores.Users.Exists(ctx, req.UserID)
|
||||
if !userExists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := database.InsertReturningID(`
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, teamID, req.UserID, req.Role)
|
||||
id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role)
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"})
|
||||
@@ -320,14 +291,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := database.DB.Exec(database.Q(`
|
||||
UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3
|
||||
`), req.Role, memberID, teamID)
|
||||
n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
||||
return
|
||||
}
|
||||
@@ -344,12 +313,12 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
memberID := c.Param("memberId")
|
||||
|
||||
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1 AND team_id = $2`), memberID, teamID)
|
||||
n, err := h.stores.Teams.DeleteMemberByID(c.Request.Context(), memberID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
||||
return
|
||||
}
|
||||
@@ -384,6 +353,7 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
// GET /api/v1/teams/:teamId/models
|
||||
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
type availableModel struct {
|
||||
ID string `json:"id"`
|
||||
@@ -395,79 +365,62 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
models := make([]availableModel, 0)
|
||||
result := make([]availableModel, 0)
|
||||
|
||||
// ── 1. Global admin models (synced in model_catalog) ──
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
|
||||
ac.provider, ac.name as provider_name
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs ac ON mc.provider_config_id = ac.id
|
||||
WHERE mc.visibility IN ('enabled', 'team')
|
||||
AND ac.is_active = true AND ac.scope = 'global'
|
||||
ORDER BY ac.name, mc.model_id
|
||||
`))
|
||||
catalogModels, err := h.stores.Catalog.ListTeamAvailable(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var m availableModel
|
||||
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
|
||||
&m.Provider, &m.ProviderName); err != nil {
|
||||
continue
|
||||
}
|
||||
m.Source = "global"
|
||||
models = append(models, m)
|
||||
for _, cm := range catalogModels {
|
||||
result = append(result, availableModel{
|
||||
ID: cm.ID,
|
||||
ModelID: cm.ModelID,
|
||||
DisplayName: cm.DisplayName,
|
||||
Visibility: cm.Visibility,
|
||||
Provider: cm.Provider,
|
||||
ProviderName: cm.ProviderName,
|
||||
Source: "global",
|
||||
})
|
||||
}
|
||||
|
||||
// ── 2. Team provider models (live query) ──
|
||||
teamRows, err := database.DB.Query(database.Q(`
|
||||
SELECT id, name, provider, endpoint, api_key_enc, headers
|
||||
FROM provider_configs
|
||||
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
|
||||
`), teamID)
|
||||
teamConfigs, err := h.stores.Providers.ListForTeam(ctx, teamID)
|
||||
if err == nil {
|
||||
defer teamRows.Close()
|
||||
for teamRows.Next() {
|
||||
var cfgID, name, providerID, endpoint string
|
||||
var apiKey *string
|
||||
var headersJSON []byte
|
||||
if err := teamRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
provider, pErr := providers.Get(providerID)
|
||||
for _, cfg := range teamConfigs {
|
||||
provider, pErr := providers.Get(cfg.Provider)
|
||||
if pErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key := ""
|
||||
if apiKey != nil {
|
||||
key = *apiKey
|
||||
if cfg.HasKey() {
|
||||
key = string(cfg.APIKeyEnc)
|
||||
}
|
||||
|
||||
var customHeaders map[string]string
|
||||
_ = json.Unmarshal(headersJSON, &customHeaders)
|
||||
if cfg.Headers != nil {
|
||||
b, _ := json.Marshal(cfg.Headers)
|
||||
_ = json.Unmarshal(b, &customHeaders)
|
||||
}
|
||||
|
||||
provModels, lErr := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
provModels, lErr := provider.ListModels(ctx, providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
})
|
||||
if lErr != nil {
|
||||
log.Printf("[models] team provider %q list failed: %v", name, lErr)
|
||||
log.Printf("[models] team provider %q list failed: %v", cfg.Name, lErr)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pm := range provModels {
|
||||
models = append(models, availableModel{
|
||||
result = append(result, availableModel{
|
||||
ID: pm.ID,
|
||||
ModelID: pm.ID,
|
||||
Provider: providerID,
|
||||
ProviderName: name,
|
||||
Provider: cfg.Provider,
|
||||
ProviderName: cfg.Name,
|
||||
Visibility: "enabled",
|
||||
Source: "team",
|
||||
})
|
||||
@@ -475,7 +428,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||
c.JSON(http.StatusOK, gin.H{"models": result})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
@@ -488,68 +441,25 @@ func getTeamID(c *gin.Context) string {
|
||||
return c.Param("id")
|
||||
}
|
||||
|
||||
// IsTeamAdmin checks if a user is an admin of the given team.
|
||||
func IsTeamAdmin(userID, teamID string) bool {
|
||||
var role string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2
|
||||
`), teamID, userID).Scan(&role)
|
||||
return err == nil && role == "admin"
|
||||
}
|
||||
|
||||
// IsTeamMember checks if a user belongs to the given team (any role).
|
||||
func IsTeamMember(userID, teamID string) bool {
|
||||
var exists bool
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)
|
||||
`), teamID, userID).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
// enforcePrivateProviderPolicy checks if a user belongs to any team that
|
||||
// requires private providers, and if so, verifies the resolved config is
|
||||
// marked as private. Returns nil if allowed, error if blocked.
|
||||
func enforcePrivateProviderPolicy(userID, configID string) error {
|
||||
func enforcePrivateProviderPolicy(ctx context.Context, stores store.Stores, userID, configID string) error {
|
||||
if configID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if user belongs to any team with require_private_providers policy
|
||||
var requiresPrivate bool
|
||||
var query string
|
||||
if database.IsSQLite() {
|
||||
query = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = ?
|
||||
AND t.is_active = 1
|
||||
AND json_extract(t.settings, '$.require_private_providers') = 'true'
|
||||
)`
|
||||
} else {
|
||||
query = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND t.is_active = true
|
||||
AND t.settings->>'require_private_providers' = 'true'
|
||||
)`
|
||||
}
|
||||
err := database.DB.QueryRow(query, userID).Scan(&requiresPrivate)
|
||||
requiresPrivate, err := stores.Teams.HasPrivateProviderRequirement(ctx, userID)
|
||||
if err != nil || !requiresPrivate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is in a restricted team — verify the config is private
|
||||
var isPrivate bool
|
||||
err = database.DB.QueryRow(database.Q(`
|
||||
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
|
||||
`), configID).Scan(&isPrivate)
|
||||
cfg, err := stores.Providers.GetByID(ctx, configID)
|
||||
if err != nil {
|
||||
return nil // config lookup failed, allow (fail open)
|
||||
}
|
||||
if !isPrivate {
|
||||
if !cfg.IsPrivate {
|
||||
return fmt.Errorf("your team requires private providers — this provider sends data externally")
|
||||
}
|
||||
return nil
|
||||
@@ -561,84 +471,31 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// Build filter clauses — always scoped to team members.
|
||||
// Use ? placeholders and convert for Postgres if needed.
|
||||
clauses := []string{"al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
|
||||
args := []interface{}{teamID}
|
||||
opts := store.AuditListOptions{
|
||||
ListOptions: store.ListOptions{
|
||||
Limit: perPage,
|
||||
Offset: offset,
|
||||
},
|
||||
TeamID: teamID,
|
||||
}
|
||||
|
||||
if action := c.Query("action"); action != "" {
|
||||
clauses = append(clauses, "al.action = ?")
|
||||
args = append(args, action)
|
||||
opts.Action = action
|
||||
}
|
||||
if actorID := c.Query("actor_id"); actorID != "" {
|
||||
clauses = append(clauses, "al.actor_id = ?")
|
||||
args = append(args, actorID)
|
||||
opts.ActorID = actorID
|
||||
}
|
||||
if rt := c.Query("resource_type"); rt != "" {
|
||||
clauses = append(clauses, "al.resource_type = ?")
|
||||
args = append(args, rt)
|
||||
opts.ResourceType = rt
|
||||
}
|
||||
|
||||
where := "WHERE " + strings.Join(clauses, " AND ")
|
||||
|
||||
// For Postgres, convert ? to $N
|
||||
if database.IsPostgres() {
|
||||
where = convertPlaceholders(where)
|
||||
}
|
||||
|
||||
// Count
|
||||
var total int
|
||||
countArgs := make([]interface{}, len(args))
|
||||
copy(countArgs, args)
|
||||
err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query with actor name join
|
||||
limitOffset := fmt.Sprintf("LIMIT %d OFFSET %d", perPage, offset)
|
||||
query := `
|
||||
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
|
||||
al.action, al.resource_type, al.resource_id,
|
||||
COALESCE(al.metadata, '{}'), al.ip_address, al.created_at
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.actor_id = u.id
|
||||
` + where + `
|
||||
ORDER BY al.created_at DESC
|
||||
` + limitOffset
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
entries, total, err := h.stores.Audit.List(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type entry struct {
|
||||
ID string `json:"id"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
ActorName *string `json:"actor_name"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
Metadata string `json:"metadata"`
|
||||
IPAddress *string `json:"ip_address"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
entries := make([]entry, 0)
|
||||
for rows.Next() {
|
||||
var e entry
|
||||
var actorName sql.NullString
|
||||
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
|
||||
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, database.ST(&e.CreatedAt)); err != nil {
|
||||
continue
|
||||
}
|
||||
if actorName.Valid {
|
||||
e.ActorName = &actorName.String
|
||||
}
|
||||
entries = append(entries, e)
|
||||
if entries == nil {
|
||||
entries = []models.AuditEntry{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -652,39 +509,11 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT DISTINCT al.action
|
||||
FROM audit_log al
|
||||
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
|
||||
ORDER BY al.action ASC
|
||||
`), teamID)
|
||||
actions, err := h.stores.Teams.ListTeamAuditActions(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"actions": []string{}})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
actions := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if rows.Scan(&a) == nil {
|
||||
actions = append(actions, a)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"actions": actions})
|
||||
}
|
||||
|
||||
// convertPlaceholders converts ? placeholders to $1, $2, etc. for Postgres.
|
||||
func convertPlaceholders(q string) string {
|
||||
n := 1
|
||||
var result strings.Builder
|
||||
for _, ch := range q {
|
||||
if ch == '?' {
|
||||
result.WriteString(fmt.Sprintf("$%d", n))
|
||||
n++
|
||||
} else {
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user