Changeset 0.9.2 (#52)

This commit is contained in:
2026-02-23 19:31:33 +00:00
parent febcbde3d7
commit ac7c7c7d59
25 changed files with 534 additions and 54 deletions

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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})
}