257 lines
7.5 KiB
Go
257 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
var (
|
|
ErrStaffSelf = errors.New("you can't change your own staff access")
|
|
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
|
|
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
|
|
ErrRoleBadPermission = errors.New("unknown permission")
|
|
ErrRoleName = errors.New("a role needs a name")
|
|
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
|
|
)
|
|
|
|
// StaffService manages who can use the admin panel and with what powers.
|
|
// Every method here is reachable only with rbac.ManageStaff — superadmins.
|
|
type StaffService struct {
|
|
staff *repository.StaffRepo
|
|
// mail tells newly added staff about their access; nil sends nothing.
|
|
mail mailer.Mailer
|
|
logger *zap.Logger
|
|
adminURL string
|
|
}
|
|
|
|
// NewStaffService wires staff management. adminURL is the panel's address for
|
|
// the welcome email; empty falls back to the production panel.
|
|
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
|
|
if adminURL == "" {
|
|
adminURL = defaultAdminURL
|
|
}
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
|
|
}
|
|
|
|
// Me describes the caller, for the panel to decide what to show.
|
|
type Me struct {
|
|
RoleKey string `json:"role_key"`
|
|
RoleName string `json:"role_name"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
|
|
return s.staff.ListStaff(ctx)
|
|
}
|
|
|
|
// isSuperadminRole reports whether a role id is the seeded superadmin.
|
|
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
|
|
ro, err := s.staff.Role(ctx, roleID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return ro.Key == repository.SuperadminKey, nil
|
|
}
|
|
|
|
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
|
|
// out of that state if they're the last one — demoting, revoking or blocking
|
|
// them would leave no one able to manage staff, with no way back from the UI.
|
|
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
|
|
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
|
|
return nil
|
|
}
|
|
n, err := s.staff.ActiveSuperadmins(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n <= 1 {
|
|
return ErrLastSuperadmin
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Grant gives an account a role, by handle.
|
|
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
|
|
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if userID == actorID {
|
|
return nil, ErrStaffSelf
|
|
}
|
|
if _, err := s.staff.Role(ctx, roleID); err != nil {
|
|
return nil, err
|
|
}
|
|
// Granting to someone who is already staff is a role change, and must
|
|
// pass the same last-superadmin check.
|
|
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
|
|
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
|
|
}
|
|
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
|
|
return nil, err
|
|
}
|
|
m, err := s.staff.StaffMember(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Only a NEW staff member is welcomed; role changes above stay quiet.
|
|
s.sendStaffWelcome(m)
|
|
return m, nil
|
|
}
|
|
|
|
// ChangeRole moves a staff member to another role.
|
|
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
|
|
if targetID == actorID {
|
|
return nil, ErrStaffSelf
|
|
}
|
|
target, err := s.staff.StaffMember(ctx, targetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toSuper, err := s.isSuperadminRole(ctx, roleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !toSuper {
|
|
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.staff.StaffMember(ctx, targetID)
|
|
}
|
|
|
|
// Revoke removes a staff member's access entirely.
|
|
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
|
|
if targetID == actorID {
|
|
return ErrStaffSelf
|
|
}
|
|
target, err := s.staff.StaffMember(ctx, targetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
|
return err
|
|
}
|
|
return s.staff.SetRole(ctx, targetID, nil)
|
|
}
|
|
|
|
// SetBlocked suspends or restores a staff member's panel access, keeping their
|
|
// role so restoring them is one action rather than a re-grant.
|
|
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
|
|
if targetID == actorID {
|
|
return nil, ErrStaffSelf
|
|
}
|
|
target, err := s.staff.StaffMember(ctx, targetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if blocked {
|
|
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.staff.StaffMember(ctx, targetID)
|
|
}
|
|
|
|
// ── Roles ─────────────────────────────────────────────────────────────────
|
|
|
|
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
|
|
return s.staff.ListRoles(ctx)
|
|
}
|
|
|
|
// cleanPermissions validates a custom role's permissions against the catalog.
|
|
// The wildcard and admins:manage aren't in the catalog, so they are refused
|
|
// here — a custom role can never become a second superadmin.
|
|
func cleanPermissions(perms []string) ([]string, error) {
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for _, p := range perms {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" || seen[p] {
|
|
continue
|
|
}
|
|
if !rbac.Valid(p) {
|
|
return nil, ErrRoleBadPermission
|
|
}
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, ErrRoleNoPermissions
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
func roleKeyFor(name string) string {
|
|
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
|
if len(k) > 40 {
|
|
k = k[:40]
|
|
}
|
|
return k
|
|
}
|
|
|
|
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
|
|
name = strings.TrimSpace(name)
|
|
key := roleKeyFor(name)
|
|
if name == "" || key == "" {
|
|
return nil, ErrRoleName
|
|
}
|
|
clean, err := cleanPermissions(perms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
|
|
}
|
|
|
|
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
|
|
ro, err := s.staff.Role(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ro.IsSystem {
|
|
return nil, ErrRoleSystem
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, ErrRoleName
|
|
}
|
|
clean, err := cleanPermissions(perms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.staff.Role(ctx, id)
|
|
}
|
|
|
|
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
|
|
ro, err := s.staff.Role(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ro.IsSystem {
|
|
return ErrRoleSystem
|
|
}
|
|
return s.staff.DeleteRole(ctx, id)
|
|
}
|