feat(account): add admin settings matrix management (#445)
This commit is contained in:
parent
7a0e9526c8
commit
e7a8b9738a
164
account/api/admin_settings_test.go
Normal file
164
account/api/admin_settings_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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)")
|
||||
|
||||
17
account/internal/model/admin_setting.go
Normal file
17
account/internal/model/admin_setting.go
Normal file
@ -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" }
|
||||
195
account/internal/service/admin_settings.go
Normal file
195
account/internal/service/admin_settings.go
Normal file
@ -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
|
||||
}
|
||||
23
account/sql/20250305-admin-settings.sql
Normal file
23
account/sql/20250305-admin-settings.sql
Normal file
@ -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;
|
||||
@ -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
|
||||
-- =========================================
|
||||
|
||||
33
docs/account-admin-settings.md
Normal file
33
docs/account-admin-settings.md
Normal file
@ -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
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user