From d5009762360927a4f24d8698471434746a783d3f Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 16 Mar 2026 09:24:49 +0800 Subject: [PATCH] feat(auth): add readonly review account --- api/admin_users_metrics.go | 40 +++++++++++--- cmd/accountsvc/main.go | 102 +++++++++++++++++++++++++++++++++++ config/account-server.yaml | 17 ++++++ config/account.cloudrun.yaml | 17 ++++++ config/account.yaml | 17 ++++++ config/config.go | 32 +++++++---- 6 files changed, 208 insertions(+), 17 deletions(-) diff --git a/api/admin_users_metrics.go b/api/admin_users_metrics.go index d242c26..be63fd6 100644 --- a/api/admin_users_metrics.go +++ b/api/admin_users_metrics.go @@ -105,17 +105,29 @@ func (h *handler) requireAdminPermission(c *gin.Context, permission string) (*st return user, true } - if !store.IsOperatorRole(user.Role) { - respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions") - return nil, false + if store.IsOperatorRole(user.Role) { + if permission != "" && !h.operatorPermissionAllowed(c, permission) { + respondError(c, http.StatusForbidden, "forbidden", "operator permission denied") + return nil, false + } + return user, true } - if permission != "" && !h.operatorPermissionAllowed(c, permission) { - respondError(c, http.StatusForbidden, "forbidden", "operator permission denied") - return nil, false + if strings.EqualFold(strings.TrimSpace(user.Role), store.RoleReadOnly) { + method := c.Request.Method + if method != http.MethodGet && method != http.MethodHead { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return nil, false + } + if permission == "" || !hasPermission(user.Permissions, permission) { + respondError(c, http.StatusForbidden, "forbidden", "readonly permission denied") + return nil, false + } + return user, true } - return user, true + respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions") + return nil, false } func (h *handler) requireAdminOrOperator(c *gin.Context) (*store.User, bool) { @@ -141,6 +153,20 @@ func (h *handler) operatorPermissionAllowed(c *gin.Context, permission string) b return allowed } +func hasPermission(permissions []string, target string) bool { + target = strings.TrimSpace(target) + if target == "" { + return false + } + for _, permission := range permissions { + normalized := strings.TrimSpace(permission) + if normalized == "*" || strings.EqualFold(normalized, target) { + return true + } + } + return false +} + func (h *handler) resolveSessionToken(c *gin.Context) string { token := extractToken(c.GetHeader("Authorization")) if token == "" { diff --git a/cmd/accountsvc/main.go b/cmd/accountsvc/main.go index ecb2f3d..6db8272 100644 --- a/cmd/accountsvc/main.go +++ b/cmd/accountsvc/main.go @@ -44,6 +44,8 @@ var ( const ( // SandboxEmail is the canonical email for the sandbox account. SandboxEmail = "sandbox@svc.plus" + // ReviewEmail is the canonical email for the readonly App Review account. + ReviewEmail = "review@svc.plus" ) const ( @@ -51,6 +53,103 @@ const ( rootBootstrapPasswordEnv = "ROOT_BOOTSTRAP_PASSWORD" ) +var defaultReviewPermissions = []string{ + "admin.settings.read", + "admin.users.metrics.read", + "admin.users.list.read", + "admin.agents.status.read", + "admin.blacklist.read", +} + +func ensureReviewUser(ctx context.Context, st store.Store, cfg config.ReviewAccount, logger *slog.Logger) error { + email := strings.ToLower(strings.TrimSpace(cfg.Email)) + if email == "" { + email = ReviewEmail + } + name := strings.TrimSpace(cfg.Name) + if name == "" { + name = "Review" + } + groups := cfg.Groups + if len(groups) == 0 { + groups = []string{"User", "Beta", "Review", "ReadOnly Role"} + } + permissions := cfg.Permissions + if len(permissions) == 0 { + permissions = defaultReviewPermissions + } + + reviewUser, err := st.GetUserByEmail(ctx, email) + if err != nil && !errors.Is(err, store.ErrUserNotFound) { + return fmt.Errorf("lookup review user: %w", err) + } + + if !cfg.Enabled { + if reviewUser != nil && reviewUser.Active { + reviewUser.Active = false + if err := st.UpdateUser(ctx, reviewUser); err != nil { + return fmt.Errorf("disable review user: %w", err) + } + if logger != nil { + logger.Info("review account disabled", "email", email) + } + } + return nil + } + + password := strings.TrimSpace(cfg.Password) + if password == "" { + return fmt.Errorf("review account %q enabled without password", email) + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash review password: %w", err) + } + + if reviewUser == nil { + user := &store.User{ + Name: name, + Email: email, + EmailVerified: true, + PasswordHash: string(hashed), + Level: store.LevelUser, + Role: store.RoleReadOnly, + Groups: groups, + Permissions: permissions, + Active: true, + } + if err := st.CreateUser(ctx, user); err != nil { + return fmt.Errorf("create review user: %w", err) + } + if logger != nil { + logger.Info("review account ensured", "email", email, "created", true) + } + return nil + } + + reviewUser.Name = name + reviewUser.Email = email + reviewUser.EmailVerified = true + reviewUser.PasswordHash = string(hashed) + reviewUser.Role = store.RoleReadOnly + reviewUser.Level = store.LevelUser + reviewUser.Groups = groups + reviewUser.Permissions = permissions + reviewUser.Active = true + reviewUser.MFATOTPSecret = "" + reviewUser.MFAEnabled = false + reviewUser.MFASecretIssuedAt = time.Time{} + reviewUser.MFAConfirmedAt = time.Time{} + if err := st.UpdateUser(ctx, reviewUser); err != nil { + return fmt.Errorf("update review user: %w", err) + } + if logger != nil { + logger.Info("review account ensured", "email", email, "created", false) + } + return nil +} + type mailerAdapter struct { sender mailer.Sender } @@ -596,6 +695,9 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err logger.Warn("failed to ensure sandbox user", "err", err) } startSandboxUUIDRotator(ctx, st, logger) + if err := ensureReviewUser(ctx, st, cfg.ReviewAccount, logger); err != nil { + logger.Warn("failed to ensure review user", "err", err) + } var emailSender api.EmailSender emailVerificationEnabled := true diff --git a/config/account-server.yaml b/config/account-server.yaml index c321343..381c1a5 100644 --- a/config/account-server.yaml +++ b/config/account-server.yaml @@ -84,3 +84,20 @@ agents: token: "replace-with-agent-token" groups: - "default" + +reviewAccount: + enabled: true + email: "review@svc.plus" + name: "Review" + password: "Review123!" + groups: + - "User" + - "Beta" + - "Review" + - "ReadOnly Role" + permissions: + - "admin.settings.read" + - "admin.users.metrics.read" + - "admin.users.list.read" + - "admin.agents.status.read" + - "admin.blacklist.read" diff --git a/config/account.cloudrun.yaml b/config/account.cloudrun.yaml index f3d6603..b524e5f 100644 --- a/config/account.cloudrun.yaml +++ b/config/account.cloudrun.yaml @@ -67,6 +67,23 @@ xray: - "restart" - "xray.service" +reviewAccount: + enabled: true + email: "review@svc.plus" + name: "Review" + password: "Review123!" + groups: + - "User" + - "Beta" + - "Review" + - "ReadOnly Role" + permissions: + - "admin.settings.read" + - "admin.users.metrics.read" + - "admin.users.list.read" + - "admin.agents.status.read" + - "admin.blacklist.read" + agent: id: "account-primary" controllerUrl: "http://127.0.0.1:8080" diff --git a/config/account.yaml b/config/account.yaml index 5c44a04..e7a4dea 100644 --- a/config/account.yaml +++ b/config/account.yaml @@ -107,3 +107,20 @@ agents: token: "replace-with-agent-token" groups: - "default" + +reviewAccount: + enabled: true + email: "review@svc.plus" + name: "Review" + password: "Review123!" + groups: + - "User" + - "Beta" + - "Review" + - "ReadOnly Role" + permissions: + - "admin.settings.read" + - "admin.users.metrics.read" + - "admin.users.list.read" + - "admin.agents.status.read" + - "admin.blacklist.read" diff --git a/config/config.go b/config/config.go index b5ebda6..168bb84 100644 --- a/config/config.go +++ b/config/config.go @@ -19,16 +19,17 @@ type Log struct { // Config holds configuration for the account service. type Config struct { - Mode string `yaml:"mode"` - Log Log `yaml:"log"` - Server Server `yaml:"server"` - Store Store `yaml:"store"` - Session Session `yaml:"session"` - Auth Auth `yaml:"auth"` - SMTP SMTP `yaml:"smtp"` - Xray Xray `yaml:"xray"` - Agent Agent `yaml:"agent"` - Agents Agents `yaml:"agents"` + Mode string `yaml:"mode"` + Log Log `yaml:"log"` + Server Server `yaml:"server"` + Store Store `yaml:"store"` + Session Session `yaml:"session"` + Auth Auth `yaml:"auth"` + SMTP SMTP `yaml:"smtp"` + Xray Xray `yaml:"xray"` + Agent Agent `yaml:"agent"` + Agents Agents `yaml:"agents"` + ReviewAccount ReviewAccount `yaml:"reviewAccount"` } // Server defines HTTP server configuration. @@ -163,6 +164,17 @@ type Agents struct { Credentials []AgentCredential `yaml:"credentials"` } +// ReviewAccount controls the built-in readonly review user intended for App +// Review and feature validation. +type ReviewAccount struct { + Enabled bool `yaml:"enabled"` + Email string `yaml:"email"` + Name string `yaml:"name"` + Password string `yaml:"password"` + Groups []string `yaml:"groups"` + Permissions []string `yaml:"permissions"` +} + // AgentCredential represents a single agent identity authorised to call the // controller API. type AgentCredential struct {