feat(auth): add readonly review account
This commit is contained in:
parent
e4db51ed67
commit
d500976236
@ -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 == "" {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user