From 85a7d7e560771e41fcbd48315720b3cb4e79ee48 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 4 Feb 2026 13:36:24 +0800 Subject: [PATCH] feat: enforce root account and introduce RBAC policy scaffolding --- api/admin_agents.go | 2 +- api/admin_users.go | 35 ++- api/admin_users_metrics.go | 85 +++++++- api/api.go | 48 ++++- cmd/accountsvc/main.go | 281 ++++++++++++++++++++++++- cmd/createadmin/main.go | 31 +-- docs/usage/config.md | 14 ++ internal/store/postgres.go | 2 +- internal/store/store.go | 34 ++- sql/20260204_rbac_root_constraints.sql | 70 ++++++ sql/readme.md | 14 ++ sql/schema.sql | 76 ++++++- 12 files changed, 647 insertions(+), 45 deletions(-) create mode 100644 sql/20260204_rbac_root_constraints.sql diff --git a/api/admin_agents.go b/api/admin_agents.go index 3ece44d..5566c6e 100644 --- a/api/admin_agents.go +++ b/api/admin_agents.go @@ -37,7 +37,7 @@ func (h *handler) adminAgentStatus(c *gin.Context) { return } - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminAgentsStatus); !ok { return } diff --git a/api/admin_users.go b/api/admin_users.go index 57b0cfb..05db6f1 100644 --- a/api/admin_users.go +++ b/api/admin_users.go @@ -9,7 +9,7 @@ import ( ) func (h *handler) pauseUser(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersPause); !ok { return } @@ -19,6 +19,10 @@ func (h *handler) pauseUser(c *gin.Context) { respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user") return } + if h.isRootAccount(user) { + respondError(c, http.StatusForbidden, "root_protected", "root account cannot be paused") + return + } user.Active = false if err := h.store.UpdateUser(c.Request.Context(), user); err != nil { @@ -30,7 +34,7 @@ func (h *handler) pauseUser(c *gin.Context) { } func (h *handler) resumeUser(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersResume); !ok { return } @@ -40,6 +44,10 @@ func (h *handler) resumeUser(c *gin.Context) { respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user") return } + if h.isRootAccount(user) { + respondError(c, http.StatusForbidden, "root_protected", "root account is always active") + return + } user.Active = true if err := h.store.UpdateUser(c.Request.Context(), user); err != nil { @@ -51,11 +59,20 @@ func (h *handler) resumeUser(c *gin.Context) { } func (h *handler) deleteUser(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersDelete); !ok { return } userID := c.Param("userId") + user, err := h.store.GetUserByID(c.Request.Context(), userID) + if err != nil { + respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user") + return + } + if h.isRootAccount(user) { + respondError(c, http.StatusForbidden, "root_protected", "root account cannot be deleted") + return + } if err := h.store.DeleteUser(c.Request.Context(), userID); err != nil { respondError(c, http.StatusInternalServerError, "delete_failed", "failed to delete user") return @@ -65,7 +82,7 @@ func (h *handler) deleteUser(c *gin.Context) { } func (h *handler) renewProxyUUID(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersRenewUUID); !ok { return } @@ -84,6 +101,10 @@ func (h *handler) renewProxyUUID(c *gin.Context) { respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user") return } + if h.isRootAccount(user) { + respondError(c, http.StatusForbidden, "root_protected", "root account UUID cannot be renewed") + return + } // Generate new UUID // We use crypto/rand usually, but for simplicity here we assume a helper or just a placeholder @@ -126,7 +147,7 @@ func (h *handler) renewProxyUUID(c *gin.Context) { } func (h *handler) listBlacklist(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminBlacklistRead); !ok { return } @@ -140,7 +161,7 @@ func (h *handler) listBlacklist(c *gin.Context) { } func (h *handler) addToBlacklist(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminBlacklistWrite); !ok { return } @@ -161,7 +182,7 @@ func (h *handler) addToBlacklist(c *gin.Context) { } func (h *handler) removeFromBlacklist(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminBlacklistWrite); !ok { return } diff --git a/api/admin_users_metrics.go b/api/admin_users_metrics.go index 7f42106..18d4dfa 100644 --- a/api/admin_users_metrics.go +++ b/api/admin_users_metrics.go @@ -11,13 +11,43 @@ import ( "account/internal/store" ) +const ( + permissionAdminSettingsRead = "admin.settings.read" + permissionAdminSettingsWrite = "admin.settings.write" + permissionAdminUsersMetrics = "admin.users.metrics.read" + permissionAdminUsersListRead = "admin.users.list.read" + permissionAdminAgentsStatus = "admin.agents.status.read" + permissionAdminUsersPause = "admin.users.pause.write" + permissionAdminUsersResume = "admin.users.resume.write" + permissionAdminUsersDelete = "admin.users.delete.write" + permissionAdminUsersRenewUUID = "admin.users.renew_uuid.write" + permissionAdminUsersRoleWrite = "admin.users.role.write" + permissionAdminBlacklistRead = "admin.blacklist.read" + permissionAdminBlacklistWrite = "admin.blacklist.write" +) + +var defaultOperatorPermissions = map[string]bool{ + permissionAdminSettingsRead: true, + permissionAdminSettingsWrite: false, + permissionAdminUsersMetrics: true, + permissionAdminUsersListRead: true, + permissionAdminAgentsStatus: true, + permissionAdminUsersPause: true, + permissionAdminUsersResume: true, + permissionAdminUsersDelete: false, + permissionAdminUsersRenewUUID: true, + permissionAdminUsersRoleWrite: false, + permissionAdminBlacklistRead: true, + permissionAdminBlacklistWrite: true, +} + func (h *handler) adminUsersMetrics(c *gin.Context) { if h.metricsProvider == nil { respondError(c, http.StatusServiceUnavailable, "metrics_unavailable", "user metrics provider is not configured") return } - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersMetrics); !ok { return } @@ -36,7 +66,7 @@ func (h *handler) adminUsersMetrics(c *gin.Context) { c.JSON(http.StatusOK, metrics) } -func (h *handler) requireAdminOrOperator(c *gin.Context) (*store.User, bool) { +func (h *handler) requireAdminPermission(c *gin.Context, permission string) (*store.User, bool) { token := h.resolveSessionToken(c) if token == "" { respondError(c, http.StatusUnauthorized, "session_token_required", "session token is required") @@ -54,20 +84,63 @@ func (h *handler) requireAdminOrOperator(c *gin.Context) (*store.User, bool) { respondError(c, http.StatusInternalServerError, "session_user_lookup_failed", "failed to load session user") return nil, false } - - role := strings.ToLower(strings.TrimSpace(user.Role)) - if role != store.RoleAdmin && role != store.RoleOperator { - respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions") + if !user.Active { + respondError(c, http.StatusForbidden, "account_suspended", "your account has been suspended") return nil, false } + if h.isReadOnlyAccount(user) && c.Request.Method != http.MethodGet { respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") return nil, false } + if store.IsRootRole(user.Role) { + if !strings.EqualFold(strings.TrimSpace(user.Email), store.RootAdminEmail) { + respondError(c, http.StatusForbidden, "root_email_enforced", "root role is restricted to admin@svc.plus") + return nil, false + } + return user, true + } + if strings.EqualFold(strings.TrimSpace(user.Role), store.RoleAdmin) { + return user, true + } + + if !store.IsOperatorRole(user.Role) { + respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions") + return nil, false + } + + if permission != "" && !h.operatorPermissionAllowed(c, permission) { + respondError(c, http.StatusForbidden, "forbidden", "operator permission denied") + return nil, false + } + return user, true } +func (h *handler) requireAdminOrOperator(c *gin.Context) (*store.User, bool) { + return h.requireAdminPermission(c, "") +} + +func (h *handler) operatorPermissionAllowed(c *gin.Context, permission string) bool { + defaultAllowed := defaultOperatorPermissions[permission] + settings, err := service.GetAdminSettings(c.Request.Context()) + if err != nil { + return defaultAllowed + } + + module, ok := settings.Matrix[permission] + if !ok { + return defaultAllowed + } + + allowed, ok := module[store.RoleOperator] + if !ok { + return defaultAllowed + } + return allowed +} + func (h *handler) resolveSessionToken(c *gin.Context) string { token := extractToken(c.GetHeader("Authorization")) if token == "" { diff --git a/api/api.go b/api/api.go index 9535b33..2149cb8 100644 --- a/api/api.go +++ b/api/api.go @@ -790,14 +790,22 @@ func (h *handler) confirmPasswordReset(c *gin.Context) { }) } -var allowedAdminRoles = map[string]struct{}{ - "admin": {}, - "operator": {}, - "user": {}, +var allowedPermissionMatrixRoles = map[string]struct{}{ + store.RoleRoot: {}, + store.RoleOperator: {}, + store.RoleUser: {}, + store.RoleReadOnly: {}, + store.RoleAdmin: {}, +} + +var assignableUserRoles = map[string]struct{}{ + store.RoleOperator: {}, + store.RoleUser: {}, + store.RoleReadOnly: {}, } func (h *handler) getAdminSettings(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminSettingsRead); !ok { return } settings, err := service.GetAdminSettings(c.Request.Context()) @@ -816,7 +824,7 @@ func (h *handler) getAdminSettings(c *gin.Context) { } func (h *handler) updateAdminSettings(c *gin.Context) { - adminUser, ok := h.requireAdminOrOperator(c) + adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite) if !ok { return } @@ -886,7 +894,7 @@ func normalizeAdminMatrix(in map[string]map[string]bool) (map[string]map[string] if key == "" { return nil, errors.New("role cannot be empty") } - if _, ok := allowedAdminRoles[key]; !ok { + if _, ok := allowedPermissionMatrixRoles[key]; !ok { return nil, fmt.Errorf("unsupported role: %s", role) } normalizedRoles[key] = enabled @@ -2469,7 +2477,7 @@ func (h *handler) oauthCallback(c *gin.Context) { } func (h *handler) listUsers(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersListRead); !ok { return } @@ -2488,7 +2496,7 @@ func (h *handler) listUsers(c *gin.Context) { } func (h *handler) updateUserRole(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersRoleWrite); !ok { return } @@ -2507,7 +2515,7 @@ func (h *handler) updateUserRole(c *gin.Context) { } role := strings.ToLower(strings.TrimSpace(req.Role)) - if _, ok := allowedAdminRoles[role]; !ok { + if _, ok := assignableUserRoles[role]; !ok { respondError(c, http.StatusBadRequest, "invalid_role", "specified role is not allowed") return } @@ -2521,6 +2529,10 @@ func (h *handler) updateUserRole(c *gin.Context) { respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user") return } + if h.isRootAccount(user) { + respondError(c, http.StatusForbidden, "root_protected", "root account role cannot be modified") + return + } user.Role = role // Role field update will trigger Level update in store if implemented according to plan @@ -2534,7 +2546,7 @@ func (h *handler) updateUserRole(c *gin.Context) { } func (h *handler) resetUserRole(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + if _, ok := h.requireAdminPermission(c, permissionAdminUsersRoleWrite); !ok { return } @@ -2553,6 +2565,10 @@ func (h *handler) resetUserRole(c *gin.Context) { respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user") return } + if h.isRootAccount(user) { + respondError(c, http.StatusForbidden, "root_protected", "root account role cannot be modified") + return + } user.Role = store.RoleUser if err := h.store.UpdateUser(c.Request.Context(), user); err != nil { @@ -2573,6 +2589,9 @@ func (h *handler) isReadOnlyAccount(user *store.User) bool { if user == nil { return false } + if strings.EqualFold(strings.TrimSpace(user.Role), store.RoleReadOnly) { + return true + } name := strings.TrimSpace(user.Name) email := strings.TrimSpace(user.Email) if strings.EqualFold(name, "demo") || strings.EqualFold(email, "demo@svc.plus") { @@ -2586,6 +2605,13 @@ func (h *handler) isReadOnlyAccount(user *store.User) bool { return false } +func (h *handler) isRootAccount(user *store.User) bool { + if user == nil { + return false + } + return store.IsRootRole(user.Role) && strings.EqualFold(strings.TrimSpace(user.Email), store.RootAdminEmail) +} + func respondError(c *gin.Context, status int, code, message string) { c.JSON(status, gin.H{ "error": code, diff --git a/cmd/accountsvc/main.go b/cmd/accountsvc/main.go index 7b9d8c4..cb18057 100644 --- a/cmd/accountsvc/main.go +++ b/cmd/accountsvc/main.go @@ -48,6 +48,9 @@ const ( demoEmail = "demo@svc.plus" demoGroup = "ReadOnly Role" demoUUIDTTL = time.Hour + + rootUsername = "admin" + rootBootstrapPasswordEnv = "ROOT_BOOTSTRAP_PASSWORD" ) type mailerAdapter struct { @@ -133,7 +136,7 @@ func ensureDemoUser(ctx context.Context, st store.Store, logger *slog.Logger) er EmailVerified: true, PasswordHash: string(hashed), Level: store.LevelUser, - Role: store.RoleUser, + Role: store.RoleReadOnly, Groups: []string{demoGroup}, Permissions: []string{}, Active: true, @@ -154,7 +157,7 @@ func ensureDemoUser(ctx context.Context, st store.Store, logger *slog.Logger) er demoUser.EmailVerified = true demoUser.PasswordHash = string(hashed) demoUser.Level = store.LevelUser - demoUser.Role = store.RoleUser + demoUser.Role = store.RoleReadOnly demoUser.Groups = []string{demoGroup} demoUser.Permissions = []string{} demoUser.Active = true @@ -232,6 +235,272 @@ func startDemoUUIDRotator(ctx context.Context, st store.Store, logger *slog.Logg }() } +func ensureRootUser(ctx context.Context, st store.Store, logger *slog.Logger) error { + users, err := st.ListUsers(ctx) + if err != nil { + return fmt.Errorf("list users for root check: %w", err) + } + + var rootUser *store.User + for i := range users { + user := users[i] + if strings.EqualFold(strings.TrimSpace(user.Email), store.RootAdminEmail) { + candidate := user + rootUser = &candidate + break + } + } + + if rootUser == nil { + bootstrapPassword := strings.TrimSpace(os.Getenv(rootBootstrapPasswordEnv)) + if bootstrapPassword == "" { + return fmt.Errorf("root account %q missing: set %s to bootstrap it", store.RootAdminEmail, rootBootstrapPasswordEnv) + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(bootstrapPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash root bootstrap password: %w", err) + } + + root := &store.User{ + Name: rootUsername, + Email: store.RootAdminEmail, + PasswordHash: string(hashed), + EmailVerified: true, + Role: store.RoleRoot, + Level: store.LevelAdmin, + Groups: []string{"Admin"}, + Permissions: []string{"*"}, + Active: true, + } + if err := st.CreateUser(ctx, root); err != nil { + return fmt.Errorf("create root user: %w", err) + } + rootUser = root + if logger != nil { + logger.Warn("root account bootstrapped from environment variable", "email", store.RootAdminEmail) + } + } + + if rootUser != nil { + updatedRoot := *rootUser + if enforceRootProfile(&updatedRoot) { + if err := st.UpdateUser(ctx, &updatedRoot); err != nil { + return fmt.Errorf("enforce root profile: %w", err) + } + rootUser = &updatedRoot + if logger != nil { + logger.Info("root profile normalized", "email", store.RootAdminEmail, "userID", rootUser.ID) + } + } + } + + for i := range users { + user := users[i] + if rootUser != nil && user.ID == rootUser.ID { + continue + } + if !store.IsAdminRole(user.Role) { + continue + } + + updated := user + updated.Role = store.RoleOperator + updated.Level = store.LevelOperator + updated.Permissions = dropPermission(updated.Permissions, "*") + updated.Groups = dropGroup(updated.Groups, "Admin") + if len(updated.Groups) == 0 { + updated.Groups = []string{"Operator"} + } + + if err := st.UpdateUser(ctx, &updated); err != nil { + return fmt.Errorf("demote legacy root/admin user %q: %w", user.Email, err) + } + if logger != nil { + logger.Warn("demoted legacy root/admin account to operator", "userID", updated.ID, "email", updated.Email) + } + } + + return nil +} + +func enforceRootProfile(user *store.User) bool { + if user == nil { + return false + } + + changed := false + if !strings.EqualFold(strings.TrimSpace(user.Email), store.RootAdminEmail) { + user.Email = store.RootAdminEmail + changed = true + } + if strings.ToLower(strings.TrimSpace(user.Role)) != store.RoleRoot { + user.Role = store.RoleRoot + changed = true + } + if user.Level != store.LevelAdmin { + user.Level = store.LevelAdmin + changed = true + } + if !user.Active { + user.Active = true + changed = true + } + if !user.EmailVerified { + user.EmailVerified = true + changed = true + } + if !containsCaseInsensitive(user.Groups, "Admin") { + user.Groups = append(user.Groups, "Admin") + changed = true + } + if !containsExactValue(user.Permissions, "*") { + user.Permissions = append(user.Permissions, "*") + changed = true + } + return changed +} + +func dropPermission(values []string, permission string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) == permission { + continue + } + result = append(result, value) + } + return result +} + +func dropGroup(values []string, group string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), group) { + continue + } + result = append(result, value) + } + return result +} + +func containsCaseInsensitive(values []string, target string) bool { + target = strings.TrimSpace(target) + if target == "" { + return false + } + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), target) { + return true + } + } + return false +} + +func containsExactValue(values []string, target string) bool { + target = strings.TrimSpace(target) + if target == "" { + return false + } + for _, value := range values { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} + +func applyRBACSchema(ctx context.Context, db *gorm.DB, driver string) error { + if db == nil { + return errors.New("database is nil") + } + + normalized := strings.ToLower(strings.TrimSpace(driver)) + if normalized != "postgres" && normalized != "postgresql" && normalized != "pgx" { + return nil + } + + statements := []string{ + `CREATE TABLE IF NOT EXISTS public.rbac_roles ( + role_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + `CREATE TABLE IF NOT EXISTS public.rbac_permissions ( + permission_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + `CREATE TABLE IF NOT EXISTS public.rbac_role_permissions ( + role_key TEXT NOT NULL REFERENCES public.rbac_roles(role_key) ON DELETE CASCADE, + permission_key TEXT NOT NULL REFERENCES public.rbac_permissions(permission_key) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (role_key, permission_key) +)`, + `CREATE UNIQUE INDEX IF NOT EXISTS users_single_root_role_uk ON public.users ((lower(role))) WHERE lower(role) = 'root'`, + `DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'users_root_email_ck' + ) THEN + ALTER TABLE public.users + ADD CONSTRAINT users_root_email_ck + CHECK (lower(role) <> 'root' OR lower(email) = 'admin@svc.plus'); + END IF; +END +$$`, + } + + for _, stmt := range statements { + if err := db.WithContext(ctx).Exec(stmt).Error; err != nil { + return err + } + } + + seedStatements := []string{ + `INSERT INTO public.rbac_roles (role_key, description, priority) +VALUES + ('root', 'single root account', 0), + ('operator', 'operation role with configurable permissions', 10), + ('user', 'standard subscription user', 20), + ('readonly', 'read-only experience account', 30) +ON CONFLICT (role_key) DO NOTHING`, + `INSERT INTO public.rbac_permissions (permission_key, description) +VALUES + ('admin.settings.read', 'read admin matrix settings'), + ('admin.settings.write', 'update admin matrix settings'), + ('admin.users.metrics.read', 'read user metrics'), + ('admin.users.list.read', 'read user list'), + ('admin.agents.status.read', 'read agent status'), + ('admin.users.pause.write', 'pause users'), + ('admin.users.resume.write', 'resume users'), + ('admin.users.delete.write', 'delete users'), + ('admin.users.renew_uuid.write', 'renew user proxy uuid'), + ('admin.users.role.write', 'update/reset user role'), + ('admin.blacklist.read', 'read blacklist'), + ('admin.blacklist.write', 'update blacklist') +ON CONFLICT (permission_key) DO NOTHING`, + `INSERT INTO public.rbac_role_permissions (role_key, permission_key, enabled) +SELECT 'operator', permission_key, true +FROM public.rbac_permissions +ON CONFLICT (role_key, permission_key) DO NOTHING`, + } + + for _, stmt := range seedStatements { + if err := db.WithContext(ctx).Exec(stmt).Error; err != nil { + return err + } + } + + return nil +} + func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) error { if ctx == nil { ctx = context.Background() @@ -278,6 +547,10 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err } }() + if err := ensureRootUser(ctx, st, logger); err != nil { + return err + } + if err := ensureDemoUser(ctx, st, logger); err != nil { return err } @@ -351,6 +624,10 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err }() service.SetDB(gormDB) + if err := applyRBACSchema(ctx, gormDB, cfg.Store.Driver); err != nil { + return fmt.Errorf("apply rbac schema: %w", err) + } + gormSource, err := xrayconfig.NewGormClientSource(gormDB) if err != nil { return err diff --git a/cmd/createadmin/main.go b/cmd/createadmin/main.go index 0291913..423562a 100644 --- a/cmd/createadmin/main.go +++ b/cmd/createadmin/main.go @@ -22,9 +22,9 @@ func main() { var ( driver = flag.String("driver", "postgres", "database driver (postgres, memory)") dsn = flag.String("dsn", "", "database connection string") - username = flag.String("username", "", "super administrator username") - password = flag.String("password", "", "super administrator password") - email = flag.String("email", "", "super administrator email (optional)") + username = flag.String("username", "", "root username") + password = flag.String("password", "", "root password") + email = flag.String("email", store.RootAdminEmail, "root email (must be admin@svc.plus)") groups = flag.String("groups", "", "comma separated list of groups to assign (optional)") permissions = flag.String("permissions", "", "comma separated list of permissions to assign (optional)") currentPassword = flag.String("current-password", "", "current super administrator password (required when updating)") @@ -51,6 +51,9 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas if username == "" { return errors.New("username is required") } + if !strings.EqualFold(email, store.RootAdminEmail) { + return fmt.Errorf("root email must be %q", store.RootAdminEmail) + } if dsn == "" && !strings.EqualFold(driver, "memory") { return errors.New("dsn is required") } @@ -75,9 +78,13 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas configuredGroups := parseCSV(groups) configuredPermissions := parseCSV(permissions) - user, err := s.GetUserByName(ctx, username) - if err != nil { - if !errors.Is(err, store.ErrUserNotFound) { + user, err := s.GetUserByEmail(ctx, store.RootAdminEmail) + if err != nil && !errors.Is(err, store.ErrUserNotFound) { + return err + } + if errors.Is(err, store.ErrUserNotFound) { + user, err = s.GetUserByName(ctx, username) + if err != nil && !errors.Is(err, store.ErrUserNotFound) { return err } } @@ -89,7 +96,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas if user == nil { if superAdminCount > 0 { - return errors.New("super administrator already exists") + return errors.New("root administrator already exists") } if password == "" { return errors.New("password is required") @@ -105,7 +112,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas Email: email, PasswordHash: string(hashed), Level: store.LevelAdmin, - Role: store.RoleAdmin, + Role: store.RoleRoot, Groups: ensureSuperAdminGroups(configuredGroups, nil), Permissions: ensureSuperAdminPermissions(configuredPermissions, nil), EmailVerified: true, @@ -126,7 +133,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas } if superAdminCount > 1 { - return errors.New("multiple super administrators detected; resolve manually before continuing") + return errors.New("multiple root administrators detected; resolve manually before continuing") } if user.PasswordHash != "" { @@ -157,9 +164,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas } updated := *user - if email != "" { - updated.Email = email - } + updated.Email = store.RootAdminEmail if password != "" { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -171,7 +176,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas updated.Groups = ensureSuperAdminGroups(configuredGroups, user.Groups) updated.Permissions = ensureSuperAdminPermissions(configuredPermissions, user.Permissions) updated.EmailVerified = updated.Email != "" - updated.Role = store.RoleAdmin + updated.Role = store.RoleRoot updated.Level = store.LevelAdmin updated.UpdatedAt = time.Now().UTC() diff --git a/docs/usage/config.md b/docs/usage/config.md index 8a70708..a0b45b8 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -85,6 +85,20 @@ auth: 说明:启用后会为 `/api/auth/*` 的保护路由添加 JWT 中间件。 +### Root / RBAC 约束 + +- 系统仅允许一个 root 账号,固定邮箱:`admin@svc.plus`。 +- 服务启动会自动执行 root 自检: + - 若缺失 root 且未设置 `ROOT_BOOTSTRAP_PASSWORD`,服务启动失败; + - 若存在旧版 `admin` 角色账号,会自动降级为 `operator`。 +- 首次引导 root 账号时可设置环境变量: + +```bash +ROOT_BOOTSTRAP_PASSWORD='YOUR_PASSWORD-now' +``` + +- `Demo` 体验账号固定为只读分组 `ReadOnly Role`,并使用 `readonly` 角色。 + ## smtp ```yaml diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 98da709..b075da3 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -559,7 +559,7 @@ func (s *postgresStore) CountSuperAdmins(ctx context.Context) (int, error) { roleClauses := make([]string, 0, 2) if caps.hasRole { - roleClauses = append(roleClauses, "lower(role) = 'admin'") + roleClauses = append(roleClauses, "lower(role) IN ('root','admin')") } if caps.hasLevel { roleClauses = append(roleClauses, fmt.Sprintf("level = %d", LevelAdmin)) diff --git a/internal/store/store.go b/internal/store/store.go index d66535c..67a26f9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -441,6 +441,11 @@ func (s *memoryStore) CountSuperAdmins(ctx context.Context) (int, error) { return count, nil } +const ( + // RootAdminEmail is the canonical email for the single root account. + RootAdminEmail = "admin@svc.plus" +) + const ( // LevelAdmin is the numeric level for administrator accounts. LevelAdmin = 0 @@ -451,27 +456,50 @@ const ( ) const ( - // RoleAdmin identifies administrator accounts. + // RoleRoot identifies the single root administrator account. + RoleRoot = "root" + // RoleAdmin identifies legacy administrator accounts from earlier versions. RoleAdmin = "admin" // RoleOperator identifies operator accounts. RoleOperator = "operator" // RoleUser identifies standard user accounts. RoleUser = "user" + // RoleReadOnly identifies read-only accounts. + RoleReadOnly = "readonly" ) var ( roleToLevel = map[string]int{ + RoleRoot: LevelAdmin, RoleAdmin: LevelAdmin, RoleOperator: LevelOperator, RoleUser: LevelUser, + RoleReadOnly: LevelUser, } levelToRole = map[int]string{ - LevelAdmin: RoleAdmin, + LevelAdmin: RoleRoot, LevelOperator: RoleOperator, LevelUser: RoleUser, } ) +// IsRootRole reports whether a role should be treated as root-equivalent. +func IsRootRole(role string) bool { + normalized := strings.ToLower(strings.TrimSpace(role)) + return normalized == RoleRoot +} + +// IsAdminRole reports whether a role is admin-like (root or legacy admin). +func IsAdminRole(role string) bool { + normalized := strings.ToLower(strings.TrimSpace(role)) + return normalized == RoleRoot || normalized == RoleAdmin +} + +// IsOperatorRole reports whether a role is operator. +func IsOperatorRole(role string) bool { + return strings.ToLower(strings.TrimSpace(role)) == RoleOperator +} + func normalizeUserRoleFields(user *User) { if user == nil { return @@ -579,7 +607,7 @@ func isSuperAdmin(user *User) bool { if user == nil { return false } - if strings.ToLower(strings.TrimSpace(user.Role)) != RoleAdmin && user.Level != LevelAdmin { + if !IsAdminRole(user.Role) && user.Level != LevelAdmin { return false } diff --git a/sql/20260204_rbac_root_constraints.sql b/sql/20260204_rbac_root_constraints.sql new file mode 100644 index 0000000..506ce0d --- /dev/null +++ b/sql/20260204_rbac_root_constraints.sql @@ -0,0 +1,70 @@ +-- Idempotent RBAC/root migration for existing deployments. +-- Apply with a privileged DB user before restarting account service in production. + +CREATE TABLE IF NOT EXISTS public.rbac_roles ( + role_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.rbac_permissions ( + permission_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.rbac_role_permissions ( + role_key TEXT NOT NULL REFERENCES public.rbac_roles(role_key) ON DELETE CASCADE, + permission_key TEXT NOT NULL REFERENCES public.rbac_permissions(permission_key) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (role_key, permission_key) +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_single_root_role_uk + ON public.users ((lower(role))) + WHERE lower(role) = 'root'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'users_root_email_ck' + ) THEN + ALTER TABLE public.users + ADD CONSTRAINT users_root_email_ck + CHECK (lower(role) <> 'root' OR lower(email) = 'admin@svc.plus'); + END IF; +END +$$; + +INSERT INTO public.rbac_roles (role_key, description, priority) VALUES + ('root', 'single root account', 0), + ('operator', 'operation role with configurable permissions', 10), + ('user', 'standard subscription user', 20), + ('readonly', 'read-only experience account', 30) +ON CONFLICT (role_key) DO NOTHING; + +INSERT INTO public.rbac_permissions (permission_key, description) VALUES + ('admin.settings.read', 'read admin matrix settings'), + ('admin.settings.write', 'update admin matrix settings'), + ('admin.users.metrics.read', 'read user metrics'), + ('admin.agents.status.read', 'read agent status'), + ('admin.users.pause.write', 'pause users'), + ('admin.users.resume.write', 'resume users'), + ('admin.users.delete.write', 'delete users'), + ('admin.users.renew_uuid.write', 'renew user proxy uuid'), + ('admin.users.role.write', 'update/reset user role'), + ('admin.blacklist.read', 'read blacklist'), + ('admin.blacklist.write', 'update blacklist') +ON CONFLICT (permission_key) DO NOTHING; + +INSERT INTO public.rbac_role_permissions (role_key, permission_key, enabled) +SELECT 'operator', permission_key, true +FROM public.rbac_permissions +ON CONFLICT (role_key, permission_key) DO NOTHING; diff --git a/sql/readme.md b/sql/readme.md index ad73bee..069f671 100644 --- a/sql/readme.md +++ b/sql/readme.md @@ -1,5 +1,19 @@ # Account 数据库结构与双向同步指南 +## RBAC / Root 迁移 + +- 新增迁移脚本:`sql/20260204_rbac_root_constraints.sql` +- 目的: + - 创建 RBAC 元数据表(`rbac_roles` / `rbac_permissions` / `rbac_role_permissions`) + - 增加 root 唯一约束(仅允许一个 `role=root`) + - 增加 root 邮箱约束(`role=root` 必须是 `admin@svc.plus`) + +执行示例: + +```bash +psql "$DB_URL" -v ON_ERROR_STOP=1 -f sql/20260204_rbac_root_constraints.sql +``` + ## 方案概览 | 目标 | 推荐方案 | 说明 | diff --git a/sql/schema.sql b/sql/schema.sql index c9e0be0..d4b67fd 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -14,6 +14,9 @@ DROP TABLE IF EXISTS public.identities CASCADE; DROP TABLE IF EXISTS public.users CASCADE; DROP TABLE IF EXISTS public.admin_settings CASCADE; DROP TABLE IF EXISTS public.subscriptions CASCADE; +DROP TABLE IF EXISTS public.rbac_role_permissions CASCADE; +DROP TABLE IF EXISTS public.rbac_permissions CASCADE; +DROP TABLE IF EXISTS public.rbac_roles CASCADE; -- ========================================= -- Extensions @@ -79,7 +82,8 @@ CREATE TABLE public.users ( email_verified BOOLEAN GENERATED ALWAYS AS ((email_verified_at IS NOT NULL)) STORED, active BOOLEAN NOT NULL DEFAULT TRUE, proxy_uuid UUID NOT NULL DEFAULT gen_random_uuid(), - proxy_uuid_expires_at TIMESTAMPTZ + proxy_uuid_expires_at TIMESTAMPTZ, + CONSTRAINT users_root_email_ck CHECK (lower(role) <> 'root' OR lower(email) = 'admin@svc.plus') ); CREATE TABLE public.email_blacklist ( @@ -123,6 +127,30 @@ CREATE TABLE public.admin_settings ( CONSTRAINT admin_settings_module_role_uk UNIQUE (module_key, role) ); +CREATE TABLE public.rbac_roles ( + role_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE public.rbac_permissions ( + permission_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE public.rbac_role_permissions ( + role_key TEXT NOT NULL REFERENCES public.rbac_roles(role_key) ON DELETE CASCADE, + permission_key TEXT NOT NULL REFERENCES public.rbac_permissions(permission_key) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (role_key, permission_key) +); + CREATE TABLE public.subscriptions ( uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_uuid UUID NOT NULL REFERENCES public.users(uuid) ON DELETE CASCADE, @@ -160,6 +188,7 @@ CREATE TABLE public.nodes ( -- ========================================= CREATE UNIQUE INDEX users_username_lower_uk ON public.users (lower(username)); CREATE UNIQUE INDEX users_email_lower_uk ON public.users (lower(email)) WHERE email IS NOT NULL; +CREATE UNIQUE INDEX users_single_root_role_uk ON public.users ((lower(role))) WHERE lower(role) = 'root'; CREATE INDEX idx_identities_user_uuid ON public.identities (user_uuid); CREATE INDEX idx_sessions_user_uuid ON public.sessions (user_uuid); CREATE INDEX idx_admin_settings_version ON public.admin_settings (version); @@ -211,6 +240,21 @@ CREATE TRIGGER trg_admin_settings_bump_version BEFORE UPDATE ON public.admin_settings FOR EACH ROW EXECUTE FUNCTION public.bump_version(); +-- rbac_roles +CREATE TRIGGER trg_rbac_roles_set_updated_at + BEFORE UPDATE ON public.rbac_roles + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- rbac_permissions +CREATE TRIGGER trg_rbac_permissions_set_updated_at + BEFORE UPDATE ON public.rbac_permissions + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- rbac_role_permissions +CREATE TRIGGER trg_rbac_role_permissions_set_updated_at + BEFORE UPDATE ON public.rbac_role_permissions + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + -- subscriptions CREATE TRIGGER trg_subscriptions_set_updated_at BEFORE UPDATE ON public.subscriptions @@ -224,3 +268,33 @@ CREATE TRIGGER trg_nodes_set_updated_at CREATE TRIGGER trg_nodes_bump_version BEFORE UPDATE ON public.nodes FOR EACH ROW EXECUTE FUNCTION public.bump_version(); + +-- ========================================= +-- Seed RBAC +-- ========================================= +INSERT INTO public.rbac_roles (role_key, description, priority) VALUES + ('root', 'single root account', 0), + ('operator', 'operation role with configurable permissions', 10), + ('user', 'standard subscription user', 20), + ('readonly', 'read-only experience account', 30) +ON CONFLICT (role_key) DO NOTHING; + +INSERT INTO public.rbac_permissions (permission_key, description) VALUES + ('admin.settings.read', 'read admin matrix settings'), + ('admin.settings.write', 'update admin matrix settings'), + ('admin.users.metrics.read', 'read user metrics'), + ('admin.users.list.read', 'read user list'), + ('admin.agents.status.read', 'read agent status'), + ('admin.users.pause.write', 'pause users'), + ('admin.users.resume.write', 'resume users'), + ('admin.users.delete.write', 'delete users'), + ('admin.users.renew_uuid.write', 'renew user proxy uuid'), + ('admin.users.role.write', 'update/reset user role'), + ('admin.blacklist.read', 'read blacklist'), + ('admin.blacklist.write', 'update blacklist') +ON CONFLICT (permission_key) DO NOTHING; + +INSERT INTO public.rbac_role_permissions (role_key, permission_key, enabled) +SELECT 'operator', permission_key, true +FROM public.rbac_permissions +ON CONFLICT (role_key, permission_key) DO NOTHING;