Changeset 0.9.2 (#52)
This commit is contained in:
@@ -26,6 +26,13 @@ JWT_ISSUER=chat-switchboard
|
||||
SWITCHBOARD_ADMIN_USERNAME=
|
||||
SWITCHBOARD_ADMIN_PASSWORD=
|
||||
|
||||
# ── Seed Users (dev/test only) ──────────────
|
||||
# Pre-create users on startup. Ignored in production.
|
||||
# Format: user:pass:role,user2:pass2:role2 (role = admin|user, default: user)
|
||||
# Idempotent: existing usernames are skipped.
|
||||
# K8s: create secret "switchboard-seed-users" with key "users"
|
||||
SEED_USERS=
|
||||
|
||||
# ── CORS ─────────────────────────────────────
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ type Config struct {
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
AdminEmail string
|
||||
|
||||
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
|
||||
SeedUsers string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
@@ -35,6 +38,7 @@ func Load() *Config {
|
||||
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
|
||||
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
||||
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
||||
SeedUsers: getEnv("SEED_USERS", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
server/database/migrations/002_ci_username.sql
Normal file
14
server/database/migrations/002_ci_username.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 002_ci_username.sql
|
||||
-- Case-insensitive unique indexes for username and email.
|
||||
-- Replaces the default UNIQUE constraint (which is case-sensitive).
|
||||
|
||||
-- Normalize existing rows to lowercase first
|
||||
UPDATE users SET username = LOWER(username) WHERE username != LOWER(username);
|
||||
UPDATE users SET email = LOWER(email) WHERE email != LOWER(email);
|
||||
|
||||
-- Drop old case-sensitive unique constraints and add case-insensitive indexes
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key;
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -71,8 +72,8 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Username: strings.ToLower(req.Username),
|
||||
Email: strings.ToLower(req.Email),
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
@@ -243,7 +244,20 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"configs": cfgs})
|
||||
|
||||
// Redact API keys but expose has_key flag for UI
|
||||
type configWithKey struct {
|
||||
models.ProviderConfig
|
||||
HasKey bool `json:"has_key"`
|
||||
}
|
||||
out := make([]configWithKey, len(cfgs))
|
||||
for i, cfg := range cfgs {
|
||||
out[i] = configWithKey{
|
||||
ProviderConfig: cfg,
|
||||
HasKey: cfg.APIKeyEnc != "",
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"configs": out})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||
@@ -293,7 +307,22 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
|
||||
c.JSON(http.StatusCreated, cfg)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": cfg.ID,
|
||||
"scope": cfg.Scope,
|
||||
"name": cfg.Name,
|
||||
"provider": cfg.Provider,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"model_default": cfg.ModelDefault,
|
||||
"config": cfg.Config,
|
||||
"headers": cfg.Headers,
|
||||
"settings": cfg.Settings,
|
||||
"is_active": cfg.IsActive,
|
||||
"is_private": cfg.IsPrivate,
|
||||
"has_key": cfg.APIKeyEnc != "",
|
||||
"created_at": cfg.CreatedAt,
|
||||
"updated_at": cfg.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -77,8 +78,8 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
|
||||
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Username: strings.ToLower(req.Username),
|
||||
Email: strings.ToLower(req.Email),
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleUser,
|
||||
IsActive: defaultActive,
|
||||
@@ -269,8 +270,8 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: cfg.AdminUsername,
|
||||
Email: email,
|
||||
Username: strings.ToLower(cfg.AdminUsername),
|
||||
Email: strings.ToLower(email),
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleAdmin,
|
||||
IsActive: true,
|
||||
@@ -283,6 +284,83 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||||
}
|
||||
|
||||
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||||
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user".
|
||||
// Upsert: existing users get their password and role refreshed on every restart.
|
||||
// Gated to non-production environments.
|
||||
func SeedUsers(cfg *config.Config, s store.Stores) {
|
||||
if cfg.SeedUsers == "" {
|
||||
log.Printf(" ℹ SEED_USERS not set, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.Environment == "production" {
|
||||
log.Printf("⚠ SEED_USERS ignored in production environment")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
entries := strings.Split(cfg.SeedUsers, ",")
|
||||
log.Printf(" 🌱 SEED_USERS: %d entries to process", len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
log.Printf("⚠ Seed user skipped (bad format, want user:pass[:role]): %q", entry)
|
||||
continue
|
||||
}
|
||||
|
||||
username := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
password := strings.TrimSpace(parts[1])
|
||||
role := models.UserRoleUser
|
||||
if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" {
|
||||
role = models.UserRoleAdmin
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
log.Printf("⚠ Seed user skipped (empty username or password)")
|
||||
continue
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Seed user '%s' skipped (hash error): %v", username, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Upsert: update password+role if user exists, create if not
|
||||
existing, _ := s.Users.GetByUsername(ctx, username)
|
||||
if existing != nil {
|
||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"role": role,
|
||||
"is_active": true,
|
||||
})
|
||||
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
|
||||
continue
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: username + "@switchboard.local",
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.Users.Create(ctx, user); err != nil {
|
||||
log.Printf("⚠ Seed user '%s' failed: %v", username, err)
|
||||
continue
|
||||
}
|
||||
log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role)
|
||||
}
|
||||
}
|
||||
|
||||
// IsRegistrationEnabled checks the platform policy.
|
||||
func IsRegistrationEnabled(s store.Stores) bool {
|
||||
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
||||
|
||||
@@ -631,3 +631,125 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Team Audit Log (scoped to team members) ─
|
||||
|
||||
// ListTeamAuditLog returns paginated audit entries where the actor is a member
|
||||
// of the specified team. Team admins see only their team's activity; system
|
||||
// admins see everything (but the scoping still applies via the same query).
|
||||
// GET /api/v1/teams/:teamId/audit?page=1&per_page=50&action=...&actor_id=...&resource_type=...
|
||||
func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// Build filter clauses — always scoped to team members
|
||||
where := "WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"
|
||||
args := []interface{}{teamID}
|
||||
argN := 2
|
||||
|
||||
if action := c.Query("action"); action != "" {
|
||||
where += " AND al.action = $" + strconv.Itoa(argN)
|
||||
args = append(args, action)
|
||||
argN++
|
||||
}
|
||||
if actorID := c.Query("actor_id"); actorID != "" {
|
||||
where += " AND al.actor_id = $" + strconv.Itoa(argN)
|
||||
args = append(args, actorID)
|
||||
argN++
|
||||
}
|
||||
if rt := c.Query("resource_type"); rt != "" {
|
||||
where += " AND al.resource_type = $" + strconv.Itoa(argN)
|
||||
args = append(args, rt)
|
||||
argN++
|
||||
}
|
||||
|
||||
// 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
|
||||
query := `
|
||||
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
|
||||
al.action, al.resource_type, al.resource_id,
|
||||
COALESCE(al.metadata::text, '{}'), 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
|
||||
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
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 string `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, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if actorName.Valid {
|
||||
e.ActorName = &actorName.String
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// ListTeamAuditActions returns distinct action names for audit entries within
|
||||
// the team scope, for filter dropdowns.
|
||||
// GET /api/v1/teams/:teamId/audit/actions
|
||||
func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
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)
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func main() {
|
||||
|
||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||
handlers.BootstrapAdmin(cfg, stores)
|
||||
|
||||
// Seed additional users from env (dev/test only, skipped in production)
|
||||
handlers.SeedUsers(cfg, stores)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
@@ -194,6 +197,10 @@ func main() {
|
||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||
|
||||
// Team audit log (scoped to team members)
|
||||
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||
|
||||
// Team personas
|
||||
teamPersonas := handlers.NewPersonaHandler(stores)
|
||||
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
||||
|
||||
@@ -29,10 +29,7 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri
|
||||
now := time.Now()
|
||||
for _, e := range entries {
|
||||
capsJSON := ToJSON(e.Capabilities)
|
||||
var pricingJSON []byte
|
||||
if e.Pricing != nil {
|
||||
pricingJSON = ToJSON(e.Pricing)
|
||||
}
|
||||
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
|
||||
|
||||
var existingID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
|
||||
@@ -28,11 +28,43 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
return s.getBy(ctx, "username", username)
|
||||
var u models.User
|
||||
var displayName, avatarURL sql.NullString
|
||||
var settingsJSON []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, username, email, password_hash, display_name, avatar_url,
|
||||
role, is_active, settings, created_at, updated_at, last_login_at
|
||||
FROM users WHERE LOWER(username) = LOWER($1)`, username).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.DisplayName = NullableString(displayName)
|
||||
u.AvatarURL = NullableString(avatarURL)
|
||||
ScanJSON(settingsJSON, &u.Settings)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
return s.getBy(ctx, "email", email)
|
||||
var u models.User
|
||||
var displayName, avatarURL sql.NullString
|
||||
var settingsJSON []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, username, email, password_hash, display_name, avatar_url,
|
||||
role, is_active, settings, created_at, updated_at, last_login_at
|
||||
FROM users WHERE LOWER(email) = LOWER($1)`, email).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.DisplayName = NullableString(displayName)
|
||||
u.AvatarURL = NullableString(avatarURL)
|
||||
ScanJSON(settingsJSON, &u.Settings)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
|
||||
@@ -42,7 +74,7 @@ func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User,
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, username, email, password_hash, display_name, avatar_url,
|
||||
role, is_active, settings, created_at, updated_at, last_login_at
|
||||
FROM users WHERE username = $1 OR email = $1`, login).Scan(
|
||||
FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)`, login).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user