feat: enforce root account and introduce RBAC policy scaffolding
This commit is contained in:
parent
c7e4f32ee3
commit
85a7d7e560
@ -37,7 +37,7 @@ func (h *handler) adminAgentStatus(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminAgentsStatus); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (h *handler) pauseUser(c *gin.Context) {
|
func (h *handler) pauseUser(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersPause); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,6 +19,10 @@ func (h *handler) pauseUser(c *gin.Context) {
|
|||||||
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
|
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.isRootAccount(user) {
|
||||||
|
respondError(c, http.StatusForbidden, "root_protected", "root account cannot be paused")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user.Active = false
|
user.Active = false
|
||||||
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
|
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) {
|
func (h *handler) resumeUser(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersResume); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +44,10 @@ func (h *handler) resumeUser(c *gin.Context) {
|
|||||||
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
|
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.isRootAccount(user) {
|
||||||
|
respondError(c, http.StatusForbidden, "root_protected", "root account is always active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user.Active = true
|
user.Active = true
|
||||||
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
|
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) {
|
func (h *handler) deleteUser(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersDelete); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.Param("userId")
|
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 {
|
if err := h.store.DeleteUser(c.Request.Context(), userID); err != nil {
|
||||||
respondError(c, http.StatusInternalServerError, "delete_failed", "failed to delete user")
|
respondError(c, http.StatusInternalServerError, "delete_failed", "failed to delete user")
|
||||||
return
|
return
|
||||||
@ -65,7 +82,7 @@ func (h *handler) deleteUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) renewProxyUUID(c *gin.Context) {
|
func (h *handler) renewProxyUUID(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersRenewUUID); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +101,10 @@ func (h *handler) renewProxyUUID(c *gin.Context) {
|
|||||||
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
|
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.isRootAccount(user) {
|
||||||
|
respondError(c, http.StatusForbidden, "root_protected", "root account UUID cannot be renewed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new UUID
|
// Generate new UUID
|
||||||
// We use crypto/rand usually, but for simplicity here we assume a helper or just a placeholder
|
// 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) {
|
func (h *handler) listBlacklist(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminBlacklistRead); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +161,7 @@ func (h *handler) listBlacklist(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) addToBlacklist(c *gin.Context) {
|
func (h *handler) addToBlacklist(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminBlacklistWrite); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +182,7 @@ func (h *handler) addToBlacklist(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) removeFromBlacklist(c *gin.Context) {
|
func (h *handler) removeFromBlacklist(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminBlacklistWrite); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,43 @@ import (
|
|||||||
"account/internal/store"
|
"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) {
|
func (h *handler) adminUsersMetrics(c *gin.Context) {
|
||||||
if h.metricsProvider == nil {
|
if h.metricsProvider == nil {
|
||||||
respondError(c, http.StatusServiceUnavailable, "metrics_unavailable", "user metrics provider is not configured")
|
respondError(c, http.StatusServiceUnavailable, "metrics_unavailable", "user metrics provider is not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersMetrics); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +66,7 @@ func (h *handler) adminUsersMetrics(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, metrics)
|
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)
|
token := h.resolveSessionToken(c)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
respondError(c, http.StatusUnauthorized, "session_token_required", "session token is required")
|
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")
|
respondError(c, http.StatusInternalServerError, "session_user_lookup_failed", "failed to load session user")
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if !user.Active {
|
||||||
role := strings.ToLower(strings.TrimSpace(user.Role))
|
respondError(c, http.StatusForbidden, "account_suspended", "your account has been suspended")
|
||||||
if role != store.RoleAdmin && role != store.RoleOperator {
|
|
||||||
respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions")
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.isReadOnlyAccount(user) && c.Request.Method != http.MethodGet {
|
if h.isReadOnlyAccount(user) && c.Request.Method != http.MethodGet {
|
||||||
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
|
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
|
||||||
return nil, false
|
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
|
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 {
|
func (h *handler) resolveSessionToken(c *gin.Context) string {
|
||||||
token := extractToken(c.GetHeader("Authorization"))
|
token := extractToken(c.GetHeader("Authorization"))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|||||||
48
api/api.go
48
api/api.go
@ -790,14 +790,22 @@ func (h *handler) confirmPasswordReset(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var allowedAdminRoles = map[string]struct{}{
|
var allowedPermissionMatrixRoles = map[string]struct{}{
|
||||||
"admin": {},
|
store.RoleRoot: {},
|
||||||
"operator": {},
|
store.RoleOperator: {},
|
||||||
"user": {},
|
store.RoleUser: {},
|
||||||
|
store.RoleReadOnly: {},
|
||||||
|
store.RoleAdmin: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignableUserRoles = map[string]struct{}{
|
||||||
|
store.RoleOperator: {},
|
||||||
|
store.RoleUser: {},
|
||||||
|
store.RoleReadOnly: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) getAdminSettings(c *gin.Context) {
|
func (h *handler) getAdminSettings(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminSettingsRead); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settings, err := service.GetAdminSettings(c.Request.Context())
|
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) {
|
func (h *handler) updateAdminSettings(c *gin.Context) {
|
||||||
adminUser, ok := h.requireAdminOrOperator(c)
|
adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -886,7 +894,7 @@ func normalizeAdminMatrix(in map[string]map[string]bool) (map[string]map[string]
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, errors.New("role cannot be empty")
|
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)
|
return nil, fmt.Errorf("unsupported role: %s", role)
|
||||||
}
|
}
|
||||||
normalizedRoles[key] = enabled
|
normalizedRoles[key] = enabled
|
||||||
@ -2469,7 +2477,7 @@ func (h *handler) oauthCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) listUsers(c *gin.Context) {
|
func (h *handler) listUsers(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersListRead); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2488,7 +2496,7 @@ func (h *handler) listUsers(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) updateUserRole(c *gin.Context) {
|
func (h *handler) updateUserRole(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersRoleWrite); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2507,7 +2515,7 @@ func (h *handler) updateUserRole(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
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")
|
respondError(c, http.StatusBadRequest, "invalid_role", "specified role is not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -2521,6 +2529,10 @@ func (h *handler) updateUserRole(c *gin.Context) {
|
|||||||
respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user")
|
respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.isRootAccount(user) {
|
||||||
|
respondError(c, http.StatusForbidden, "root_protected", "root account role cannot be modified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user.Role = role
|
user.Role = role
|
||||||
// Role field update will trigger Level update in store if implemented according to plan
|
// 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) {
|
func (h *handler) resetUserRole(c *gin.Context) {
|
||||||
if _, ok := h.requireAdminOrOperator(c); !ok {
|
if _, ok := h.requireAdminPermission(c, permissionAdminUsersRoleWrite); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2553,6 +2565,10 @@ func (h *handler) resetUserRole(c *gin.Context) {
|
|||||||
respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user")
|
respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.isRootAccount(user) {
|
||||||
|
respondError(c, http.StatusForbidden, "root_protected", "root account role cannot be modified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user.Role = store.RoleUser
|
user.Role = store.RoleUser
|
||||||
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
|
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 {
|
if user == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(user.Role), store.RoleReadOnly) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
name := strings.TrimSpace(user.Name)
|
name := strings.TrimSpace(user.Name)
|
||||||
email := strings.TrimSpace(user.Email)
|
email := strings.TrimSpace(user.Email)
|
||||||
if strings.EqualFold(name, "demo") || strings.EqualFold(email, "demo@svc.plus") {
|
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
|
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) {
|
func respondError(c *gin.Context, status int, code, message string) {
|
||||||
c.JSON(status, gin.H{
|
c.JSON(status, gin.H{
|
||||||
"error": code,
|
"error": code,
|
||||||
|
|||||||
@ -48,6 +48,9 @@ const (
|
|||||||
demoEmail = "demo@svc.plus"
|
demoEmail = "demo@svc.plus"
|
||||||
demoGroup = "ReadOnly Role"
|
demoGroup = "ReadOnly Role"
|
||||||
demoUUIDTTL = time.Hour
|
demoUUIDTTL = time.Hour
|
||||||
|
|
||||||
|
rootUsername = "admin"
|
||||||
|
rootBootstrapPasswordEnv = "ROOT_BOOTSTRAP_PASSWORD"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mailerAdapter struct {
|
type mailerAdapter struct {
|
||||||
@ -133,7 +136,7 @@ func ensureDemoUser(ctx context.Context, st store.Store, logger *slog.Logger) er
|
|||||||
EmailVerified: true,
|
EmailVerified: true,
|
||||||
PasswordHash: string(hashed),
|
PasswordHash: string(hashed),
|
||||||
Level: store.LevelUser,
|
Level: store.LevelUser,
|
||||||
Role: store.RoleUser,
|
Role: store.RoleReadOnly,
|
||||||
Groups: []string{demoGroup},
|
Groups: []string{demoGroup},
|
||||||
Permissions: []string{},
|
Permissions: []string{},
|
||||||
Active: true,
|
Active: true,
|
||||||
@ -154,7 +157,7 @@ func ensureDemoUser(ctx context.Context, st store.Store, logger *slog.Logger) er
|
|||||||
demoUser.EmailVerified = true
|
demoUser.EmailVerified = true
|
||||||
demoUser.PasswordHash = string(hashed)
|
demoUser.PasswordHash = string(hashed)
|
||||||
demoUser.Level = store.LevelUser
|
demoUser.Level = store.LevelUser
|
||||||
demoUser.Role = store.RoleUser
|
demoUser.Role = store.RoleReadOnly
|
||||||
demoUser.Groups = []string{demoGroup}
|
demoUser.Groups = []string{demoGroup}
|
||||||
demoUser.Permissions = []string{}
|
demoUser.Permissions = []string{}
|
||||||
demoUser.Active = true
|
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 {
|
func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) error {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
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 {
|
if err := ensureDemoUser(ctx, st, logger); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -351,6 +624,10 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
|
|||||||
}()
|
}()
|
||||||
service.SetDB(gormDB)
|
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)
|
gormSource, err := xrayconfig.NewGormClientSource(gormDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -22,9 +22,9 @@ func main() {
|
|||||||
var (
|
var (
|
||||||
driver = flag.String("driver", "postgres", "database driver (postgres, memory)")
|
driver = flag.String("driver", "postgres", "database driver (postgres, memory)")
|
||||||
dsn = flag.String("dsn", "", "database connection string")
|
dsn = flag.String("dsn", "", "database connection string")
|
||||||
username = flag.String("username", "", "super administrator username")
|
username = flag.String("username", "", "root username")
|
||||||
password = flag.String("password", "", "super administrator password")
|
password = flag.String("password", "", "root password")
|
||||||
email = flag.String("email", "", "super administrator email (optional)")
|
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)")
|
groups = flag.String("groups", "", "comma separated list of groups to assign (optional)")
|
||||||
permissions = flag.String("permissions", "", "comma separated list of permissions 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)")
|
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 == "" {
|
if username == "" {
|
||||||
return errors.New("username is required")
|
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") {
|
if dsn == "" && !strings.EqualFold(driver, "memory") {
|
||||||
return errors.New("dsn is required")
|
return errors.New("dsn is required")
|
||||||
}
|
}
|
||||||
@ -75,9 +78,13 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas
|
|||||||
configuredGroups := parseCSV(groups)
|
configuredGroups := parseCSV(groups)
|
||||||
configuredPermissions := parseCSV(permissions)
|
configuredPermissions := parseCSV(permissions)
|
||||||
|
|
||||||
user, err := s.GetUserByName(ctx, username)
|
user, err := s.GetUserByEmail(ctx, store.RootAdminEmail)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, store.ErrUserNotFound) {
|
||||||
if !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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,7 +96,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas
|
|||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
if superAdminCount > 0 {
|
if superAdminCount > 0 {
|
||||||
return errors.New("super administrator already exists")
|
return errors.New("root administrator already exists")
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return errors.New("password is required")
|
return errors.New("password is required")
|
||||||
@ -105,7 +112,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas
|
|||||||
Email: email,
|
Email: email,
|
||||||
PasswordHash: string(hashed),
|
PasswordHash: string(hashed),
|
||||||
Level: store.LevelAdmin,
|
Level: store.LevelAdmin,
|
||||||
Role: store.RoleAdmin,
|
Role: store.RoleRoot,
|
||||||
Groups: ensureSuperAdminGroups(configuredGroups, nil),
|
Groups: ensureSuperAdminGroups(configuredGroups, nil),
|
||||||
Permissions: ensureSuperAdminPermissions(configuredPermissions, nil),
|
Permissions: ensureSuperAdminPermissions(configuredPermissions, nil),
|
||||||
EmailVerified: true,
|
EmailVerified: true,
|
||||||
@ -126,7 +133,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas
|
|||||||
}
|
}
|
||||||
|
|
||||||
if superAdminCount > 1 {
|
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 != "" {
|
if user.PasswordHash != "" {
|
||||||
@ -157,9 +164,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated := *user
|
updated := *user
|
||||||
if email != "" {
|
updated.Email = store.RootAdminEmail
|
||||||
updated.Email = email
|
|
||||||
}
|
|
||||||
if password != "" {
|
if password != "" {
|
||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -171,7 +176,7 @@ func run(driver, dsn, username, password, email, groups, permissions, currentPas
|
|||||||
updated.Groups = ensureSuperAdminGroups(configuredGroups, user.Groups)
|
updated.Groups = ensureSuperAdminGroups(configuredGroups, user.Groups)
|
||||||
updated.Permissions = ensureSuperAdminPermissions(configuredPermissions, user.Permissions)
|
updated.Permissions = ensureSuperAdminPermissions(configuredPermissions, user.Permissions)
|
||||||
updated.EmailVerified = updated.Email != ""
|
updated.EmailVerified = updated.Email != ""
|
||||||
updated.Role = store.RoleAdmin
|
updated.Role = store.RoleRoot
|
||||||
updated.Level = store.LevelAdmin
|
updated.Level = store.LevelAdmin
|
||||||
updated.UpdatedAt = time.Now().UTC()
|
updated.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
|||||||
@ -85,6 +85,20 @@ auth:
|
|||||||
|
|
||||||
说明:启用后会为 `/api/auth/*` 的保护路由添加 JWT 中间件。
|
说明:启用后会为 `/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
|
## smtp
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@ -559,7 +559,7 @@ func (s *postgresStore) CountSuperAdmins(ctx context.Context) (int, error) {
|
|||||||
|
|
||||||
roleClauses := make([]string, 0, 2)
|
roleClauses := make([]string, 0, 2)
|
||||||
if caps.hasRole {
|
if caps.hasRole {
|
||||||
roleClauses = append(roleClauses, "lower(role) = 'admin'")
|
roleClauses = append(roleClauses, "lower(role) IN ('root','admin')")
|
||||||
}
|
}
|
||||||
if caps.hasLevel {
|
if caps.hasLevel {
|
||||||
roleClauses = append(roleClauses, fmt.Sprintf("level = %d", LevelAdmin))
|
roleClauses = append(roleClauses, fmt.Sprintf("level = %d", LevelAdmin))
|
||||||
|
|||||||
@ -441,6 +441,11 @@ func (s *memoryStore) CountSuperAdmins(ctx context.Context) (int, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RootAdminEmail is the canonical email for the single root account.
|
||||||
|
RootAdminEmail = "admin@svc.plus"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// LevelAdmin is the numeric level for administrator accounts.
|
// LevelAdmin is the numeric level for administrator accounts.
|
||||||
LevelAdmin = 0
|
LevelAdmin = 0
|
||||||
@ -451,27 +456,50 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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"
|
RoleAdmin = "admin"
|
||||||
// RoleOperator identifies operator accounts.
|
// RoleOperator identifies operator accounts.
|
||||||
RoleOperator = "operator"
|
RoleOperator = "operator"
|
||||||
// RoleUser identifies standard user accounts.
|
// RoleUser identifies standard user accounts.
|
||||||
RoleUser = "user"
|
RoleUser = "user"
|
||||||
|
// RoleReadOnly identifies read-only accounts.
|
||||||
|
RoleReadOnly = "readonly"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
roleToLevel = map[string]int{
|
roleToLevel = map[string]int{
|
||||||
|
RoleRoot: LevelAdmin,
|
||||||
RoleAdmin: LevelAdmin,
|
RoleAdmin: LevelAdmin,
|
||||||
RoleOperator: LevelOperator,
|
RoleOperator: LevelOperator,
|
||||||
RoleUser: LevelUser,
|
RoleUser: LevelUser,
|
||||||
|
RoleReadOnly: LevelUser,
|
||||||
}
|
}
|
||||||
levelToRole = map[int]string{
|
levelToRole = map[int]string{
|
||||||
LevelAdmin: RoleAdmin,
|
LevelAdmin: RoleRoot,
|
||||||
LevelOperator: RoleOperator,
|
LevelOperator: RoleOperator,
|
||||||
LevelUser: RoleUser,
|
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) {
|
func normalizeUserRoleFields(user *User) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return
|
return
|
||||||
@ -579,7 +607,7 @@ func isSuperAdmin(user *User) bool {
|
|||||||
if user == nil {
|
if user == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.ToLower(strings.TrimSpace(user.Role)) != RoleAdmin && user.Level != LevelAdmin {
|
if !IsAdminRole(user.Role) && user.Level != LevelAdmin {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
sql/20260204_rbac_root_constraints.sql
Normal file
70
sql/20260204_rbac_root_constraints.sql
Normal 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;
|
||||||
@ -1,5 +1,19 @@
|
|||||||
# Account 数据库结构与双向同步指南
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
## 方案概览
|
## 方案概览
|
||||||
|
|
||||||
| 目标 | 推荐方案 | 说明 |
|
| 目标 | 推荐方案 | 说明 |
|
||||||
|
|||||||
@ -14,6 +14,9 @@ DROP TABLE IF EXISTS public.identities CASCADE;
|
|||||||
DROP TABLE IF EXISTS public.users CASCADE;
|
DROP TABLE IF EXISTS public.users CASCADE;
|
||||||
DROP TABLE IF EXISTS public.admin_settings CASCADE;
|
DROP TABLE IF EXISTS public.admin_settings CASCADE;
|
||||||
DROP TABLE IF EXISTS public.subscriptions 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
|
-- Extensions
|
||||||
@ -79,7 +82,8 @@ CREATE TABLE public.users (
|
|||||||
email_verified BOOLEAN GENERATED ALWAYS AS ((email_verified_at IS NOT NULL)) STORED,
|
email_verified BOOLEAN GENERATED ALWAYS AS ((email_verified_at IS NOT NULL)) STORED,
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
proxy_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
|
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 (
|
CREATE TABLE public.email_blacklist (
|
||||||
@ -123,6 +127,30 @@ CREATE TABLE public.admin_settings (
|
|||||||
CONSTRAINT admin_settings_module_role_uk UNIQUE (module_key, role)
|
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 (
|
CREATE TABLE public.subscriptions (
|
||||||
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_uuid UUID NOT NULL REFERENCES public.users(uuid) ON DELETE CASCADE,
|
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_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_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_identities_user_uuid ON public.identities (user_uuid);
|
||||||
CREATE INDEX idx_sessions_user_uuid ON public.sessions (user_uuid);
|
CREATE INDEX idx_sessions_user_uuid ON public.sessions (user_uuid);
|
||||||
CREATE INDEX idx_admin_settings_version ON public.admin_settings (version);
|
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
|
BEFORE UPDATE ON public.admin_settings
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.bump_version();
|
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
|
-- subscriptions
|
||||||
CREATE TRIGGER trg_subscriptions_set_updated_at
|
CREATE TRIGGER trg_subscriptions_set_updated_at
|
||||||
BEFORE UPDATE ON public.subscriptions
|
BEFORE UPDATE ON public.subscriptions
|
||||||
@ -224,3 +268,33 @@ CREATE TRIGGER trg_nodes_set_updated_at
|
|||||||
CREATE TRIGGER trg_nodes_bump_version
|
CREATE TRIGGER trg_nodes_bump_version
|
||||||
BEFORE UPDATE ON public.nodes
|
BEFORE UPDATE ON public.nodes
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.bump_version();
|
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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user