All checks were successful
build-and-push / build (push) Successful in 39s
- 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>
79 lines
2.4 KiB
Go
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,
|
|
})
|
|
}
|