feat(server): add admin settings matrix management (#440)

This commit is contained in:
shenlan 2025-10-07 08:48:15 +08:00 committed by GitHub
parent 6fbc0b7560
commit bd85f0fbfd
8 changed files with 414 additions and 3 deletions

4
go.mod
View File

@ -18,7 +18,8 @@ require (
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.5
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde
)
require (
@ -55,6 +56,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect

8
go.sum
View File

@ -122,6 +122,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -239,8 +241,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde h1:9DShaph9qhkIYw7QF91I/ynrr4cOO2PZra2PFD7Mfeg=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@ -0,0 +1,128 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"xcontrol/server/internal/service"
)
var allowedRoles = map[string]struct{}{
"admin": {},
"operator": {},
"user": {},
}
func registerAdminSettingRoutes(r *gin.RouterGroup) {
admin := r.Group("/admin")
admin.GET("/settings", getAdminSettings)
admin.POST("/settings", updateAdminSettings)
}
func getAdminSettings(c *gin.Context) {
if !requireAdminOrOperator(c) {
return
}
settings, err := service.GetAdminSettings(c.Request.Context())
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, service.ErrServiceDBNotInitialized) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"version": settings.Version,
"matrix": settings.Matrix,
})
}
func updateAdminSettings(c *gin.Context) {
if !requireAdminOrOperator(c) {
return
}
var req struct {
Version uint `json:"version"`
Matrix map[string]map[string]bool `json:"matrix"`
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
normalized, err := normalizeMatrix(req.Matrix)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := service.SaveAdminSettings(c.Request.Context(), service.AdminSettings{
Version: req.Version,
Matrix: normalized,
})
if err != nil {
if errors.Is(err, service.ErrAdminSettingsVersionConflict) {
c.JSON(http.StatusConflict, gin.H{
"error": err.Error(),
"version": updated.Version,
"matrix": updated.Matrix,
})
return
}
status := http.StatusInternalServerError
if errors.Is(err, service.ErrServiceDBNotInitialized) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"version": updated.Version,
"matrix": updated.Matrix,
})
}
func normalizeMatrix(in map[string]map[string]bool) (map[string]map[string]bool, error) {
if in == nil {
return make(map[string]map[string]bool), nil
}
out := make(map[string]map[string]bool, len(in))
for module, roles := range in {
moduleKey := strings.TrimSpace(module)
if moduleKey == "" {
return nil, errors.New("module key cannot be empty")
}
if roles == nil {
out[moduleKey] = make(map[string]bool)
continue
}
normalizedRoles := make(map[string]bool, len(roles))
for role, enabled := range roles {
key := strings.ToLower(strings.TrimSpace(role))
if key == "" {
return nil, errors.New("role cannot be empty")
}
if _, ok := allowedRoles[key]; !ok {
return nil, errors.New("unsupported role: " + role)
}
normalizedRoles[key] = enabled
}
out[moduleKey] = normalizedRoles
}
return out, nil
}
func requireAdminOrOperator(c *gin.Context) bool {
role := strings.ToLower(strings.TrimSpace(c.GetHeader("X-User-Role")))
if role == "" {
role = strings.ToLower(strings.TrimSpace(c.GetHeader("X-Role")))
}
if role == "admin" || role == "operator" {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return false
}

View File

@ -0,0 +1,120 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"xcontrol/server/internal/model"
"xcontrol/server/internal/service"
)
func setupAdminSettingsTestRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.AdminSetting{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
service.SetDB(db)
r := gin.New()
register := RegisterRoutes(nil, "")
register(r)
return r
}
func TestAdminSettingsReadWrite(t *testing.T) {
r := setupAdminSettingsTestRouter(t)
payload := map[string]any{
"version": 0,
"matrix": map[string]map[string]bool{
"registration": {
"admin": true,
"operator": false,
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/admin/settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Role", "admin")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp struct {
Version uint `json:"version"`
Matrix map[string]map[string]bool `json:"matrix"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Version != 1 {
t.Fatalf("expected version 1, got %d", resp.Version)
}
if resp.Matrix["registration"]["admin"] != true {
t.Fatalf("expected admin enabled")
}
// Read back using operator role.
req = httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
req.Header.Set("X-Role", "operator")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var getResp struct {
Version uint `json:"version"`
Matrix map[string]map[string]bool `json:"matrix"`
}
if err := json.Unmarshal(w.Body.Bytes(), &getResp); err != nil {
t.Fatalf("unmarshal get response: %v", err)
}
if getResp.Version != resp.Version {
t.Fatalf("expected version %d, got %d", resp.Version, getResp.Version)
}
if getResp.Matrix["registration"]["operator"] {
t.Fatalf("operator flag should be false")
}
}
func TestAdminSettingsUnauthorized(t *testing.T) {
r := setupAdminSettingsTestRouter(t)
// Missing role header.
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", w.Code)
}
// User role is not permitted.
payload := map[string]any{
"version": 0,
"matrix": map[string]map[string]bool{},
}
body, _ := json.Marshal(payload)
req = httptest.NewRequest(http.MethodPost, "/api/admin/settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Role", "user")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", w.Code)
}
}

View File

@ -16,5 +16,6 @@ func RegisterRoutes(conn *pgx.Conn, repoProxy string) server.Registrar {
registerKnowledgeRoutes(api, conn, repoProxy)
registerRAGRoutes(api)
registerAskAIRoutes(api)
registerAdminSettingRoutes(api)
}
}

View File

@ -0,0 +1,17 @@
package model
import "time"
// AdminSetting represents a single cell in the permission matrix.
type AdminSetting struct {
ID uint `gorm:"primaryKey"`
ModuleKey string `gorm:"size:128;not null;uniqueIndex:idx_admin_settings_module_role"`
Role string `gorm:"size:32;not null;uniqueIndex:idx_admin_settings_module_role"`
Enabled bool `gorm:"not null"`
Version uint `gorm:"not null;index"`
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
// TableName sets the table name for AdminSetting.
func (AdminSetting) TableName() string { return "admin_settings" }

View File

@ -0,0 +1,127 @@
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"xcontrol/server/internal/model"
)
// ErrServiceDBNotInitialized indicates the service database has not been configured.
var ErrServiceDBNotInitialized = errors.New("service db not initialized")
// ErrAdminSettingsVersionConflict is returned when the provided version does not match the stored version.
var ErrAdminSettingsVersionConflict = errors.New("admin settings version conflict")
// AdminSettings holds the permission matrix alongside its version.
type AdminSettings struct {
Version uint
Matrix map[string]map[string]bool
}
// GetAdminSettings returns the persisted permission matrix and its current version.
func GetAdminSettings(ctx context.Context) (AdminSettings, error) {
if db == nil {
return AdminSettings{}, ErrServiceDBNotInitialized
}
var rows []model.AdminSetting
if err := db.WithContext(ctx).Order("module_key ASC, role ASC").Find(&rows).Error; err != nil {
return AdminSettings{}, err
}
matrix := make(map[string]map[string]bool)
var version uint
for _, row := range rows {
module := row.ModuleKey
role := row.Role
if _, ok := matrix[module]; !ok {
matrix[module] = make(map[string]bool)
}
matrix[module][role] = row.Enabled
if row.Version > version {
version = row.Version
}
}
return AdminSettings{Version: version, Matrix: matrix}, nil
}
// SaveAdminSettings replaces the permission matrix if the provided version matches the stored version.
func SaveAdminSettings(ctx context.Context, payload AdminSettings) (AdminSettings, error) {
if db == nil {
return AdminSettings{}, ErrServiceDBNotInitialized
}
sanitized := cloneMatrix(payload.Matrix)
result := AdminSettings{Matrix: sanitized}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var currentVersion uint
if err := tx.Model(&model.AdminSetting{}).Select("COALESCE(MAX(version), 0)").Scan(&currentVersion).Error; err != nil {
return err
}
if currentVersion != payload.Version {
result.Version = currentVersion
return ErrAdminSettingsVersionConflict
}
nextVersion := currentVersion + 1
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.AdminSetting{}).Error; err != nil {
return err
}
if len(sanitized) > 0 {
rows := make([]model.AdminSetting, 0)
for module, roles := range sanitized {
module = strings.TrimSpace(module)
for role, enabled := range roles {
rows = append(rows, model.AdminSetting{
ModuleKey: module,
Role: role,
Enabled: enabled,
Version: nextVersion,
})
}
}
if len(rows) > 0 {
if err := tx.Create(&rows).Error; err != nil {
return err
}
}
}
result.Version = nextVersion
return nil
})
if err != nil {
if errors.Is(err, ErrAdminSettingsVersionConflict) {
current, getErr := GetAdminSettings(ctx)
if getErr == nil {
return current, err
}
result.Version = 0
}
return result, err
}
return result, nil
}
func cloneMatrix(in map[string]map[string]bool) map[string]map[string]bool {
if len(in) == 0 {
return make(map[string]map[string]bool)
}
out := make(map[string]map[string]bool, len(in))
for module, roles := range in {
if roles == nil {
out[module] = make(map[string]bool)
continue
}
inner := make(map[string]bool, len(roles))
for role, enabled := range roles {
inner[role] = enabled
}
out[module] = inner
}
return out
}

View File

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS admin_settings (
id SERIAL PRIMARY KEY,
module_key VARCHAR(128) NOT NULL,
role VARCHAR(32) NOT NULL,
enabled BOOLEAN NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (module_key, role)
);
CREATE INDEX IF NOT EXISTS idx_admin_settings_version ON admin_settings (version);