feat(auth): add readonly review account

This commit is contained in:
Haitao Pan 2026-03-16 09:24:49 +08:00
parent e4db51ed67
commit d500976236
6 changed files with 208 additions and 17 deletions

View File

@ -105,17 +105,29 @@ func (h *handler) requireAdminPermission(c *gin.Context, permission string) (*st
return user, true return user, true
} }
if !store.IsOperatorRole(user.Role) { if store.IsOperatorRole(user.Role) {
respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions") if permission != "" && !h.operatorPermissionAllowed(c, permission) {
return nil, false respondError(c, http.StatusForbidden, "forbidden", "operator permission denied")
return nil, false
}
return user, true
} }
if permission != "" && !h.operatorPermissionAllowed(c, permission) { if strings.EqualFold(strings.TrimSpace(user.Role), store.RoleReadOnly) {
respondError(c, http.StatusForbidden, "forbidden", "operator permission denied") method := c.Request.Method
return nil, false 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) { 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 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 { func (h *handler) resolveSessionToken(c *gin.Context) string {
token := extractToken(c.GetHeader("Authorization")) token := extractToken(c.GetHeader("Authorization"))
if token == "" { if token == "" {

View File

@ -44,6 +44,8 @@ var (
const ( const (
// SandboxEmail is the canonical email for the sandbox account. // SandboxEmail is the canonical email for the sandbox account.
SandboxEmail = "sandbox@svc.plus" SandboxEmail = "sandbox@svc.plus"
// ReviewEmail is the canonical email for the readonly App Review account.
ReviewEmail = "review@svc.plus"
) )
const ( const (
@ -51,6 +53,103 @@ const (
rootBootstrapPasswordEnv = "ROOT_BOOTSTRAP_PASSWORD" 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 { type mailerAdapter struct {
sender mailer.Sender 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) logger.Warn("failed to ensure sandbox user", "err", err)
} }
startSandboxUUIDRotator(ctx, st, logger) 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 var emailSender api.EmailSender
emailVerificationEnabled := true emailVerificationEnabled := true

View File

@ -84,3 +84,20 @@ agents:
token: "replace-with-agent-token" token: "replace-with-agent-token"
groups: groups:
- "default" - "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"

View File

@ -67,6 +67,23 @@ xray:
- "restart" - "restart"
- "xray.service" - "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: agent:
id: "account-primary" id: "account-primary"
controllerUrl: "http://127.0.0.1:8080" controllerUrl: "http://127.0.0.1:8080"

View File

@ -107,3 +107,20 @@ agents:
token: "replace-with-agent-token" token: "replace-with-agent-token"
groups: groups:
- "default" - "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"

View File

@ -19,16 +19,17 @@ type Log struct {
// Config holds configuration for the account service. // Config holds configuration for the account service.
type Config struct { type Config struct {
Mode string `yaml:"mode"` Mode string `yaml:"mode"`
Log Log `yaml:"log"` Log Log `yaml:"log"`
Server Server `yaml:"server"` Server Server `yaml:"server"`
Store Store `yaml:"store"` Store Store `yaml:"store"`
Session Session `yaml:"session"` Session Session `yaml:"session"`
Auth Auth `yaml:"auth"` Auth Auth `yaml:"auth"`
SMTP SMTP `yaml:"smtp"` SMTP SMTP `yaml:"smtp"`
Xray Xray `yaml:"xray"` Xray Xray `yaml:"xray"`
Agent Agent `yaml:"agent"` Agent Agent `yaml:"agent"`
Agents Agents `yaml:"agents"` Agents Agents `yaml:"agents"`
ReviewAccount ReviewAccount `yaml:"reviewAccount"`
} }
// Server defines HTTP server configuration. // Server defines HTTP server configuration.
@ -163,6 +164,17 @@ type Agents struct {
Credentials []AgentCredential `yaml:"credentials"` 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 // AgentCredential represents a single agent identity authorised to call the
// controller API. // controller API.
type AgentCredential struct { type AgentCredential struct {