theorose49 f83724b995
All checks were successful
build-and-push / build (push) Successful in 39s
feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier
  (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock
- 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전·
  작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계·
  유저배분·분기정산), 회계(거래·세금)
- internal/worktime: 근로기준법 월 집계 엔진
- internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션
- 시드 데이터, Go 멀티스테이지 Dockerfile
- ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:57:35 +09:00

79 lines
2.4 KiB
Go

package httpapi
import (
"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
}
// 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,
})
}