feat: quiz and rbac
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user