1
0
forked from baron/baron-sso

개발자 권한을 페이지별로 선택/부여 가능하도록 개선

This commit is contained in:
2026-06-09 16:47:20 +09:00
parent 3ed9e912e6
commit 437a3ad98d
18 changed files with 782 additions and 91 deletions

View File

@@ -2,6 +2,8 @@ package domain
import (
"time"
"github.com/lib/pq"
)
const (
@@ -11,19 +13,39 @@ const (
DeveloperRequestStatusCancelled = "cancelled"
)
const (
DeveloperAccessPageAll = "all"
DeveloperAccessPageOverview = "overview"
DeveloperAccessPageClientCreate = "client_create"
DeveloperAccessPageAudit = "audit"
)
var DeveloperAccessPageOrder = []string{
DeveloperAccessPageOverview,
DeveloperAccessPageClientCreate,
DeveloperAccessPageAudit,
}
// DeveloperRequest represents a user's application to become a developer.
type DeveloperRequest struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
TenantID string `gorm:"index;not null" json:"tenantId"`
Name string `gorm:"not null" json:"name"`
Organization string `json:"organization"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
Reason string `json:"reason"`
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
AdminNotes string `json:"adminNotes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
TenantID string `gorm:"index;not null" json:"tenantId"`
Name string `gorm:"not null" json:"name"`
Organization string `json:"organization"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
Reason string `json:"reason"`
AccessPages pq.StringArray `gorm:"type:text[]" json:"accessPages,omitempty"`
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
AdminNotes string `json:"adminNotes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DeveloperAccessStatus struct {
Status string `json:"status"`
ApprovedPages pq.StringArray `json:"approvedPages,omitempty"`
PendingPages pq.StringArray `json:"pendingPages,omitempty"`
}

View File

@@ -274,6 +274,56 @@ func isDevConsoleViewerRole(role string) bool {
return r == domain.RoleSuperAdmin || r == domain.RoleUser
}
func normalizeDeveloperAccessPagesForHandler(pages []string) []string {
seen := make(map[string]struct{})
normalized := make([]string, 0, len(pages))
add := func(page string) {
page = strings.ToLower(strings.TrimSpace(page))
if page == "" {
return
}
if page == domain.DeveloperAccessPageAll {
normalized = []string{domain.DeveloperAccessPageAll}
seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}}
return
}
for _, allowed := range domain.DeveloperAccessPageOrder {
if page == allowed {
if _, exists := seen[page]; exists {
return
}
seen[page] = struct{}{}
normalized = append(normalized, page)
return
}
}
}
for _, page := range pages {
add(page)
if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll {
return normalized
}
}
if len(normalized) == 0 {
return []string{domain.DeveloperAccessPageAll}
}
return normalized
}
func developerAccessPagesEqual(left, right []string) bool {
leftNormalized := normalizeDeveloperAccessPagesForHandler(left)
rightNormalized := normalizeDeveloperAccessPagesForHandler(right)
if len(leftNormalized) != len(rightNormalized) {
return false
}
for i := range leftNormalized {
if leftNormalized[i] != rightNormalized[i] {
return false
}
}
return true
}
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
if profile == nil {
return
@@ -3871,10 +3921,11 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
}
var req struct {
Name string `json:"name"`
Organization string `json:"organization"`
Reason string `json:"reason"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Organization string `json:"organization"`
Reason string `json:"reason"`
TenantID string `json:"tenantId"`
AccessPages []string `json:"accessPages"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -3907,6 +3958,7 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
Phone: profile.Phone,
Role: normalizeUserRole(profile.Role),
Reason: req.Reason,
AccessPages: req.AccessPages,
Status: domain.DeveloperRequestStatusPending,
}
@@ -3934,10 +3986,10 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
}
if status == nil {
return c.JSON(fiber.Map{"status": "none"})
return c.JSON(domain.DeveloperAccessStatus{Status: "none"})
}
if status.Status == domain.DeveloperRequestStatusApproved {
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
h.ensureDeveloperGrantRelation(c, profile.ID, tenantID)
}
return c.JSON(status)
@@ -4082,10 +4134,11 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
}
var reqBody struct {
UserID string `json:"userId"`
TenantID string `json:"tenantId"`
Reason string `json:"reason"`
AdminNotes string `json:"adminNotes"`
UserID string `json:"userId"`
TenantID string `json:"tenantId"`
Reason string `json:"reason"`
AdminNotes string `json:"adminNotes"`
AccessPages []string `json:"accessPages"`
}
if err := c.BodyParser(&reqBody); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -4132,11 +4185,15 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
reason = "직접 부여"
}
existing, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID)
existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if existing != nil {
for _, existing := range existingRequests {
if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) {
continue
}
switch existing.Status {
case domain.DeveloperRequestStatusApproved:
h.ensureDeveloperGrantRelation(c, userID, tenantID)
@@ -4161,6 +4218,7 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
Phone: phone,
Role: role,
Reason: reason,
AccessPages: reqBody.AccessPages,
Status: domain.DeveloperRequestStatusApproved,
AdminNotes: strings.TrimSpace(reqBody.AdminNotes),
}

View File

@@ -3,7 +3,8 @@ package service
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"sort"
"strings"
"gorm.io/gorm"
)
@@ -16,34 +17,179 @@ func NewDeveloperService(db *gorm.DB) *DeveloperService {
return &DeveloperService{db: db}
}
func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error {
// Check if there is already a pending request
var existing domain.DeveloperRequest
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error
if err == nil {
func normalizeDeveloperAccessPages(pages []string) []string {
seen := make(map[string]struct{})
normalized := make([]string, 0, len(pages))
add := func(page string) {
page = strings.ToLower(strings.TrimSpace(page))
if page == "" {
return
}
if page == domain.DeveloperAccessPageAll {
normalized = []string{domain.DeveloperAccessPageAll}
seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}}
return
}
if page != domain.DeveloperAccessPageOverview &&
page != domain.DeveloperAccessPageClientCreate &&
page != domain.DeveloperAccessPageAudit {
return
}
if _, exists := seen[page]; exists {
return
}
seen[page] = struct{}{}
normalized = append(normalized, page)
}
for _, page := range pages {
add(page)
if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll {
return normalized
}
}
if len(normalized) == 0 {
return []string{domain.DeveloperAccessPageAll}
}
sort.SliceStable(normalized, func(i, j int) bool {
return accessPageSortIndex(normalized[i]) < accessPageSortIndex(normalized[j])
})
return normalized
}
func accessPageSortIndex(page string) int {
switch page {
case domain.DeveloperAccessPageOverview:
return 0
case domain.DeveloperAccessPageClientCreate:
return 1
case domain.DeveloperAccessPageAudit:
return 2
default:
return 99
}
}
func accessPagesOverlap(left, right []string) bool {
if len(left) == 0 || len(right) == 0 {
return false
}
leftSet := make(map[string]struct{}, len(left))
for _, page := range normalizeDeveloperAccessPages(left) {
if page == domain.DeveloperAccessPageAll {
return true
}
leftSet[page] = struct{}{}
}
for _, page := range normalizeDeveloperAccessPages(right) {
if page == domain.DeveloperAccessPageAll {
return true
}
if _, ok := leftSet[page]; ok {
return true
}
}
return false
}
func unionDeveloperAccessPages(requests []domain.DeveloperRequest, statuses ...string) []string {
statusSet := make(map[string]struct{}, len(statuses))
for _, status := range statuses {
if trimmed := strings.TrimSpace(status); trimmed != "" {
statusSet[trimmed] = struct{}{}
}
}
acc := make(map[string]struct{})
for _, req := range requests {
if len(statusSet) > 0 {
if _, ok := statusSet[strings.TrimSpace(req.Status)]; !ok {
continue
}
}
pages := normalizeDeveloperAccessPages(req.AccessPages)
for _, page := range pages {
acc[page] = struct{}{}
}
}
if len(acc) == 0 {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
result := make([]string, 0, len(acc))
if _, ok := acc[domain.DeveloperAccessPageAll]; ok {
return []string{domain.DeveloperAccessPageAll}
}
for _, page := range domain.DeveloperAccessPageOrder {
if _, ok := acc[page]; ok {
result = append(result, page)
}
}
return result
}
func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error {
req.AccessPages = normalizeDeveloperAccessPages(req.AccessPages)
// Check if there is already a pending request
var existing []domain.DeveloperRequest
err := s.db.WithContext(ctx).
Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).
Order("created_at DESC").
Find(&existing).Error
if err != nil {
return err
}
for _, current := range existing {
if accessPagesOverlap(current.AccessPages, req.AccessPages) {
return nil
}
}
return s.db.WithContext(ctx).Create(&req).Error
}
func (s *DeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error {
req.AccessPages = normalizeDeveloperAccessPages(req.AccessPages)
return s.db.WithContext(ctx).Create(&req).Error
}
func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) {
var req domain.DeveloperRequest
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Order("created_at DESC").First(&req).Error
func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) {
var requests []domain.DeveloperRequest
err := s.db.WithContext(ctx).
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
Order("created_at DESC").
Find(&requests).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &req, nil
if len(requests) == 0 {
return &domain.DeveloperAccessStatus{Status: "none"}, nil
}
approvedPages := unionDeveloperAccessPages(requests, domain.DeveloperRequestStatusApproved)
pendingPages := unionDeveloperAccessPages(requests, domain.DeveloperRequestStatusPending)
status := "none"
switch {
case len(approvedPages) > 0:
status = domain.DeveloperRequestStatusApproved
case len(pendingPages) > 0:
status = domain.DeveloperRequestStatusPending
}
return &domain.DeveloperAccessStatus{
Status: status,
ApprovedPages: approvedPages,
PendingPages: pendingPages,
}, nil
}
func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) {