feat: enforce root account and introduce RBAC policy scaffolding

This commit is contained in:
Haitao Pan 2026-02-04 13:36:24 +08:00
parent c7e4f32ee3
commit 85a7d7e560
12 changed files with 647 additions and 45 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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 == "" {

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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))

View File

@ -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
}

View File

@ -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;

View File

@ -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
```
## 方案概览
| 目标 | 推荐方案 | 说明 |

View File

@ -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;