diff --git a/account/api/admin_settings_test.go b/account/api/admin_settings_test.go new file mode 100644 index 0000000..6db5c6a --- /dev/null +++ b/account/api/admin_settings_test.go @@ -0,0 +1,164 @@ +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/account/internal/model" + "xcontrol/account/internal/service" + "xcontrol/account/internal/store" +) + +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) + t.Cleanup(func() { + service.SetDB(nil) + sqlDB, _ := db.DB() + sqlDB.Close() + }) + + router := gin.New() + RegisterRoutes(router, WithStore(store.NewMemoryStore())) + return router +} + +func TestAdminSettingsReadWrite(t *testing.T) { + router := 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/auth/admin/settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-Role", "admin") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d (%s)", resp.Code, resp.Body.String()) + } + + var postResp struct { + Version uint `json:"version"` + Matrix map[string]map[string]bool `json:"matrix"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &postResp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if postResp.Version != 1 { + t.Fatalf("expected version 1, got %d", postResp.Version) + } + if !postResp.Matrix["registration"]["admin"] { + t.Fatalf("expected admin flag to be true") + } + + req = httptest.NewRequest(http.MethodGet, "/api/auth/admin/settings", nil) + req.Header.Set("X-Role", "operator") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d (%s)", resp.Code, resp.Body.String()) + } + var getResp struct { + Version uint `json:"version"` + Matrix map[string]map[string]bool `json:"matrix"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &getResp); err != nil { + t.Fatalf("unmarshal get response: %v", err) + } + if getResp.Version != postResp.Version { + t.Fatalf("expected version %d, got %d", postResp.Version, getResp.Version) + } + if getResp.Matrix["registration"]["operator"] { + t.Fatalf("expected operator flag to remain false") + } +} + +func TestAdminSettingsUnauthorized(t *testing.T) { + router := setupAdminSettingsTestRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/admin/settings", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", resp.Code) + } + + payload := map[string]any{ + "version": 0, + "matrix": map[string]map[string]bool{}, + } + body, _ := json.Marshal(payload) + req = httptest.NewRequest(http.MethodPost, "/api/auth/admin/settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-Role", "user") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", resp.Code) + } +} + +func TestAdminSettingsVersionConflict(t *testing.T) { + router := setupAdminSettingsTestRouter(t) + + payload := map[string]any{ + "version": 0, + "matrix": map[string]map[string]bool{ + "registration": {"admin": true}, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/auth/admin/settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-Role", "admin") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + + // Replay the payload with the stale version. + req = httptest.NewRequest(http.MethodPost, "/api/auth/admin/settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-Role", "admin") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusConflict { + t.Fatalf("expected status 409, got %d", resp.Code) + } + var conflict struct { + Version uint `json:"version"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &conflict); err != nil { + t.Fatalf("unmarshal conflict response: %v", err) + } + if conflict.Version != 1 { + t.Fatalf("expected current version 1, got %d", conflict.Version) + } +} diff --git a/account/api/api.go b/account/api/api.go index ca46acc..dadfa60 100644 --- a/account/api/api.go +++ b/account/api/api.go @@ -172,18 +172,26 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { }) auth := r.Group("/api/auth") + auth.POST("/register", h.register) auth.POST("/register/verify", h.verifyEmail) + auth.POST("/login", h.login) + auth.GET("/session", h.session) auth.DELETE("/session", h.deleteSession) + auth.POST("/mfa/totp/provision", h.provisionTOTP) auth.POST("/mfa/totp/verify", h.verifyTOTP) auth.POST("/mfa/disable", h.disableMFA) auth.GET("/mfa/status", h.mfaStatus) + auth.POST("/password/reset", h.requestPasswordReset) auth.POST("/password/reset/confirm", h.confirmPasswordReset) - + + auth.GET("/admin/settings", h.getAdminSettings) + auth.POST("/admin/settings", h.updateAdminSettings) + registerAdminRoutes(auth, h) } @@ -500,6 +508,119 @@ func (h *handler) confirmPasswordReset(c *gin.Context) { }) } +var allowedAdminRoles = map[string]struct{}{ + "admin": {}, + "operator": {}, + "user": {}, +} + +func (h *handler) getAdminSettings(c *gin.Context) { + if !h.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 (h *handler) updateAdminSettings(c *gin.Context) { + if !h.requireAdminOrOperator(c) { + return + } + + var req struct { + Version uint `json:"version"` + Matrix map[string]map[string]bool `json:"matrix"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + normalized, err := normalizeAdminMatrix(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 (h *handler) 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 +} + +func normalizeAdminMatrix(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 := allowedAdminRoles[key]; !ok { + return nil, fmt.Errorf("unsupported role: %s", role) + } + normalizedRoles[key] = enabled + } + out[moduleKey] = normalizedRoles + } + return out, nil +} + func (h *handler) login(c *gin.Context) { if hasQueryParameter(c, "username", "password", "identifier", "totp") { respondError(c, http.StatusBadRequest, "credentials_in_query", "sensitive credentials must not be sent in the query string") diff --git a/account/cmd/accountsvc/main.go b/account/cmd/accountsvc/main.go index c83fb46..0360f61 100644 --- a/account/cmd/accountsvc/main.go +++ b/account/cmd/accountsvc/main.go @@ -18,10 +18,15 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/spf13/cobra" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "xcontrol/account/api" "xcontrol/account/config" "xcontrol/account/internal/mailer" + "xcontrol/account/internal/model" + "xcontrol/account/internal/service" "xcontrol/account/internal/store" ) @@ -141,6 +146,19 @@ var rootCmd = &cobra.Command{ emailVerificationEnabled = false } + gormDB, gormCleanup, err := openAdminSettingsDB(cfg.Store) + if err != nil { + return err + } + defer func() { + if gormCleanup != nil { + if err := gormCleanup(context.Background()); err != nil { + logger.Error("failed to close admin settings db", "err", err) + } + } + }() + service.SetDB(gormDB) + options := []api.Option{ api.WithStore(st), api.WithSessionTTL(cfg.Session.TTL), @@ -293,6 +311,48 @@ var rootCmd = &cobra.Command{ }, } +func openAdminSettingsDB(cfg config.Store) (*gorm.DB, func(context.Context) error, error) { + driver := strings.ToLower(strings.TrimSpace(cfg.Driver)) + var ( + db *gorm.DB + err error + ) + switch driver { + case "", "memory": + db, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + case "postgres", "postgresql", "pgx": + if strings.TrimSpace(cfg.DSN) == "" { + return nil, nil, errors.New("admin settings database requires a dsn") + } + db, err = gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{}) + default: + return nil, nil, fmt.Errorf("unsupported admin settings driver %q", cfg.Driver) + } + if err != nil { + return nil, nil, err + } + + if err := db.AutoMigrate(&model.AdminSetting{}); err != nil { + return nil, nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, nil, err + } + if cfg.MaxOpenConns > 0 { + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + } + if cfg.MaxIdleConns > 0 { + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + } + + cleanup := func(context.Context) error { + return sqlDB.Close() + } + return db, cleanup, nil +} + func init() { rootCmd.Flags().StringVar(&configPath, "config", "", "path to xcontrol account configuration file") rootCmd.Flags().StringVar(&logLevel, "log-level", "", "log level (debug, info, warn, error)") diff --git a/account/internal/model/admin_setting.go b/account/internal/model/admin_setting.go new file mode 100644 index 0000000..21e5ef2 --- /dev/null +++ b/account/internal/model/admin_setting.go @@ -0,0 +1,17 @@ +package model + +import "time" + +// AdminSetting represents a single permission toggle in the admin 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 overrides the default table name used by GORM. +func (AdminSetting) TableName() string { return "admin_settings" } diff --git a/account/internal/service/admin_settings.go b/account/internal/service/admin_settings.go new file mode 100644 index 0000000..e7a2e71 --- /dev/null +++ b/account/internal/service/admin_settings.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "errors" + "strings" + "sync" + + "gorm.io/gorm" + + "xcontrol/account/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 +} + +var ( + dbMu sync.RWMutex + db *gorm.DB + cache = &adminSettingsCache{} +) + +// SetDB configures the backing database used by the admin settings service. +func SetDB(d *gorm.DB) { + dbMu.Lock() + defer dbMu.Unlock() + db = d + cache.invalidate() +} + +// GetAdminSettings returns the persisted permission matrix and its current version. +func GetAdminSettings(ctx context.Context) (AdminSettings, error) { + if cached, ok := cache.get(); ok { + return cached, nil + } + + database := currentDB() + if database == nil { + return AdminSettings{}, ErrServiceDBNotInitialized + } + + var rows []model.AdminSetting + if err := database.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 + } + } + + result := AdminSettings{Version: version, Matrix: matrix} + cache.set(result) + return result, nil +} + +// SaveAdminSettings replaces the permission matrix if the provided version matches the stored version. +func SaveAdminSettings(ctx context.Context, payload AdminSettings) (AdminSettings, error) { + sanitized := cloneMatrix(payload.Matrix) + result := AdminSettings{Matrix: sanitized} + + database := currentDB() + if database == nil { + return result, ErrServiceDBNotInitialized + } + + err := database.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) { + cache.invalidate() + current, getErr := GetAdminSettings(ctx) + if getErr == nil { + return current, err + } + result.Version = 0 + } + return result, err + } + + cache.set(result) + 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 +} + +func currentDB() *gorm.DB { + dbMu.RLock() + defer dbMu.RUnlock() + return db +} + +type adminSettingsCache struct { + mu sync.RWMutex + version uint + matrix map[string]map[string]bool + loaded bool +} + +func (c *adminSettingsCache) get() (AdminSettings, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if !c.loaded { + return AdminSettings{}, false + } + return AdminSettings{Version: c.version, Matrix: cloneMatrix(c.matrix)}, true +} + +func (c *adminSettingsCache) set(settings AdminSettings) { + c.mu.Lock() + defer c.mu.Unlock() + c.version = settings.Version + c.matrix = cloneMatrix(settings.Matrix) + c.loaded = true +} + +func (c *adminSettingsCache) invalidate() { + c.mu.Lock() + defer c.mu.Unlock() + c.loaded = false + c.version = 0 + c.matrix = nil +} diff --git a/account/sql/20250305-admin-settings.sql b/account/sql/20250305-admin-settings.sql new file mode 100644 index 0000000..1c2b99c --- /dev/null +++ b/account/sql/20250305-admin-settings.sql @@ -0,0 +1,23 @@ +-- Migration: create admin_settings table for permission matrix + +BEGIN; + +CREATE TABLE IF NOT EXISTS admin_settings ( + id BIGSERIAL PRIMARY KEY, + module_key TEXT NOT NULL, + role TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + version BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT admin_settings_module_role_uk UNIQUE (module_key, role) +); + +CREATE INDEX IF NOT EXISTS idx_admin_settings_version ON admin_settings (version); + +DROP TRIGGER IF EXISTS trg_admin_settings_set_updated_at ON admin_settings; +CREATE TRIGGER trg_admin_settings_set_updated_at +BEFORE UPDATE ON admin_settings +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +COMMIT; diff --git a/account/sql/schema.sql b/account/sql/schema.sql index 8477b54..97e88ce 100644 --- a/account/sql/schema.sql +++ b/account/sql/schema.sql @@ -78,12 +78,24 @@ CREATE TABLE IF NOT EXISTS sessions ( REFERENCES users(uuid) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS admin_settings ( + id BIGSERIAL PRIMARY KEY, + module_key TEXT NOT NULL, + role TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + version BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT admin_settings_module_role_uk UNIQUE (module_key, role) +); + -- ========================================= -- Indexes -- ========================================= CREATE INDEX IF NOT EXISTS idx_identities_provider ON identities (provider); CREATE INDEX IF NOT EXISTS idx_identities_user_uuid ON identities (user_uuid); CREATE INDEX IF NOT EXISTS idx_sessions_user_uuid ON sessions (user_uuid); +CREATE INDEX IF NOT EXISTS idx_admin_settings_version ON admin_settings (version); -- ========================================= -- Trigger @@ -93,6 +105,11 @@ CREATE TRIGGER trg_users_set_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_admin_settings_set_updated_at ON admin_settings; +CREATE TRIGGER trg_admin_settings_set_updated_at +BEFORE UPDATE ON admin_settings +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + -- ========================================= -- End of schema.sql -- ========================================= diff --git a/docs/account-admin-settings.md b/docs/account-admin-settings.md new file mode 100644 index 0000000..9996ef6 --- /dev/null +++ b/docs/account-admin-settings.md @@ -0,0 +1,33 @@ +# Account Service Admin Settings API + +This document summarizes the new `/api/auth/admin/settings` endpoints for managing the permission matrix used by the account service. + +## Endpoints + +- `GET /api/auth/admin/settings` + - Requires the caller to present `X-User-Role` or `X-Role` headers with value `admin` or `operator`. + - Returns the latest permission matrix and associated version. The handler responds with `503 Service Unavailable` when the admin settings database has not been initialised. + +- `POST /api/auth/admin/settings` + - Accepts a JSON payload containing a `version` and `matrix`. The matrix is validated to ensure module keys are non-empty and roles are within the supported set (`admin`, `operator`, `user`). + - Uses optimistic locking on the `version` field. When the provided version does not match the stored version the handler responds with `409 Conflict` and includes the authoritative matrix. + +## Storage Model + +- The permission matrix is stored in the `admin_settings` table. GORM manages the model via `account/internal/model/admin_setting.go` and a dedicated migration script (`account/sql/20250305-admin-settings.sql`). +- Each cell records `module_key`, `role`, `enabled`, and a monotonically increasing `version` value. Updates occur inside a single transaction that replaces the existing matrix to guarantee consistency across modules and roles. +- The service layer (`account/internal/service/admin_settings.go`) caches the most recent matrix in-memory and invalidates the cache whenever a write occurs or fails due to a version conflict. + +## Test Coverage + +Integration tests are provided in `account/api/admin_settings_test.go`: + +- `TestAdminSettingsReadWrite` exercises a full write followed by a read using the operator role. +- `TestAdminSettingsUnauthorized` verifies that callers without an admin/operator role receive `403 Forbidden` responses for both GET and POST. +- `TestAdminSettingsVersionConflict` validates the optimistic locking path by replaying a stale version and asserting a `409 Conflict` response that echoes the authoritative version. + +Run the suite with: + +```bash +go test ./account/api -run AdminSettings +``` diff --git a/go.mod b/go.mod index 202d986..a1962be 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( golang.org/x/crypto v0.37.0 golang.org/x/net v0.39.0 gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/postgres v1.5.4 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde )