feat(server): add admin settings matrix management (#440)
This commit is contained in:
parent
6fbc0b7560
commit
bd85f0fbfd
4
go.mod
4
go.mod
@ -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
8
go.sum
@ -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=
|
||||
|
||||
128
server/api/admin_settings.go
Normal file
128
server/api/admin_settings.go
Normal 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
|
||||
}
|
||||
120
server/api/admin_settings_test.go
Normal file
120
server/api/admin_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -16,5 +16,6 @@ func RegisterRoutes(conn *pgx.Conn, repoProxy string) server.Registrar {
|
||||
registerKnowledgeRoutes(api, conn, repoProxy)
|
||||
registerRAGRoutes(api)
|
||||
registerAskAIRoutes(api)
|
||||
registerAdminSettingRoutes(api)
|
||||
}
|
||||
}
|
||||
|
||||
17
server/internal/model/admin_setting.go
Normal file
17
server/internal/model/admin_setting.go
Normal 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" }
|
||||
127
server/internal/service/admin_settings.go
Normal file
127
server/internal/service/admin_settings.go
Normal 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(¤tVersion).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
|
||||
}
|
||||
12
server/migrations/0001_create_admin_settings.sql
Normal file
12
server/migrations/0001_create_admin_settings.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user