diff --git a/go.mod b/go.mod index 0f6b0e5..202d986 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f21af8c..2190ba1 100644 --- a/go.sum +++ b/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= diff --git a/server/api/admin_settings.go b/server/api/admin_settings.go new file mode 100644 index 0000000..f1a03b1 --- /dev/null +++ b/server/api/admin_settings.go @@ -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 +} diff --git a/server/api/admin_settings_test.go b/server/api/admin_settings_test.go new file mode 100644 index 0000000..cc186da --- /dev/null +++ b/server/api/admin_settings_test.go @@ -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) + } +} diff --git a/server/api/register.go b/server/api/register.go index 0845d85..b4babf4 100644 --- a/server/api/register.go +++ b/server/api/register.go @@ -16,5 +16,6 @@ func RegisterRoutes(conn *pgx.Conn, repoProxy string) server.Registrar { registerKnowledgeRoutes(api, conn, repoProxy) registerRAGRoutes(api) registerAskAIRoutes(api) + registerAdminSettingRoutes(api) } } diff --git a/server/internal/model/admin_setting.go b/server/internal/model/admin_setting.go new file mode 100644 index 0000000..46daafc --- /dev/null +++ b/server/internal/model/admin_setting.go @@ -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" } diff --git a/server/internal/service/admin_settings.go b/server/internal/service/admin_settings.go new file mode 100644 index 0000000..9e00b51 --- /dev/null +++ b/server/internal/service/admin_settings.go @@ -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 +} diff --git a/server/migrations/0001_create_admin_settings.sql b/server/migrations/0001_create_admin_settings.sql new file mode 100644 index 0000000..15a72e0 --- /dev/null +++ b/server/migrations/0001_create_admin_settings.sql @@ -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);