package httpapi import ( "context" "net/http" "strings" "spin/internal/models" ) // spin is a single internal company, so authorization is a 2-tier model rather // than eQMS's per-company membership matrix: // // - admin → super-admin (Keycloak group ∩ ADMIN_GROUPS) OR Member.Role==admin. // Sees and manages everything: approvals, incentive console, // accounting, all member/project data, the bracketed [admin-only] // contract & payment fields. // - member → sees only their OWN data and may only SUBMIT requests. // // Ownership is enforced per-handler by comparing the row's member email to the // caller's email (case-insensitive). // isSuperAdmin reports the Keycloak/dev super-admin flag from the auth middleware. func (s *Server) isSuperAdmin(r *http.Request) bool { return currentUser(r.Context()).IsSuperAdmin } // isAdmin reports whether the caller may manage company-wide data: either a // super-admin or a Member whose Role is admin. func (s *Server) isAdmin(r *http.Request) bool { if s.isSuperAdmin(r) { return true } m := s.lookupMember(currentUser(r.Context()).Email) return m != nil && m.Role == models.RoleAdmin } // requireAdmin writes 403 and returns false when the caller is not an admin. func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { if s.isAdmin(r) { return true } writeError(w, http.StatusForbidden, "관리자 권한이 필요합니다") return false } // email returns the caller's (lowercased) email. func (s *Server) email(r *http.Request) string { return strings.ToLower(strings.TrimSpace(currentUser(r.Context()).Email)) } // owns reports whether the given member email belongs to the caller. func (s *Server) owns(r *http.Request, memberEmail string) bool { return strings.EqualFold(strings.TrimSpace(memberEmail), s.email(r)) } // lookupMember loads the Member row matched to an email (nil if none). func (s *Server) lookupMember(email string) *models.Member { email = strings.TrimSpace(email) if email == "" { return nil } var m models.Member if err := s.db.Where("lower(email) = lower(?)", email).First(&m).Error; err != nil { return nil } return &m } // ensureMember auto-provisions a spin Member the first time an authenticated // identity is seen (Keycloak first login). rank/department stay empty (nullable) // for an admin to fill in later. Account lifecycle itself remains Keycloak's job. func (s *Server) ensureMember(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := currentUser(r.Context()) if e := strings.TrimSpace(u.Email); e != "" && s.lookupMember(e) == nil { s.db.Create(&models.Member{ Email: e, DisplayName: firstNonEmpty(u.Name, e), Role: models.RoleMember, Status: "active", }) } next.ServeHTTP(w, r) }) } // notify writes an inbox Notification and fans out a push to the recipient's // registered devices (best-effort; push is a no-op when FCM isn't configured). func (s *Server) notify(recipient, typ, title, body, link string) { if strings.TrimSpace(recipient) == "" { return } s.db.Create(&models.Notification{Recipient: recipient, Type: typ, Title: title, Body: body, Link: link}) if s.push != nil && s.push.Enabled() { var tokens []string s.db.Model(&models.Device{}).Where("lower(member_email) = lower(?)", recipient).Pluck("token", &tokens) if len(tokens) > 0 { go s.push.Send(context.Background(), tokens, title, body, link) } } } // audit writes an AuditLog row (best-effort). func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) { s.db.Create(&models.AuditLog{ Actor: currentUser(r.Context()).Email, Action: action, Entity: entity, EntityID: entityID, Detail: detail, }) }