1
0
forked from baron/baron-sso

adminfront 및 백엔드: 글로벌 사이드바 11개 전 메뉴별 ReBAC 기반 접근 제어(Admin Control) 스키마, REST API, UI 설정 패널 전격 구현 완료

This commit is contained in:
2026-06-10 16:55:34 +09:00
parent 5b4efae001
commit b4f80a36b0
12 changed files with 976 additions and 113 deletions

View File

@@ -69,24 +69,39 @@ type SignupRequest struct {
// User Profile Models
type SystemPermissions struct {
Overview bool `json:"overview"`
Tenants bool `json:"tenants"`
OrgChart bool `json:"org_chart"`
Worksmobile bool `json:"worksmobile"`
OrySSOT bool `json:"ory_ssot"`
DataIntegrity bool `json:"data_integrity"`
Users bool `json:"users"`
PermissionsDirect bool `json:"permissions_direct"`
AuthGuard bool `json:"auth_guard"`
ApiKeys bool `json:"api_keys"`
AuditLogs bool `json:"audit_logs"`
}
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
LoginID string `json:"loginId,omitempty"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
ID string `json:"id"`
Email string `json:"email"`
LoginID string `json:"loginId,omitempty"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
SystemPermissions *SystemPermissions `json:"systemPermissions,omitempty"` // [New] 글로벌 메뉴 접근 권한
}
type UpdateUserRequest struct {

View File

@@ -4770,6 +4770,81 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
}
}
if h.KetoService != nil {
subject := "User:" + profile.ID
var sp domain.SystemPermissions
if profile.Role == "super_admin" {
sp = domain.SystemPermissions{
Overview: true,
Tenants: true,
OrgChart: true,
Worksmobile: true,
OrySSOT: true,
DataIntegrity: true,
Users: true,
PermissionsDirect: true,
AuthGuard: true,
ApiKeys: true,
AuditLogs: true,
}
} else {
// Query Keto in parallel for maximum performance
type checkResult struct {
menu string
allowed bool
}
menus := map[string]string{
"overview": "access_overview",
"tenants": "access_tenants",
"org_chart": "access_org_chart",
"worksmobile": "access_worksmobile",
"ory_ssot": "access_ory_ssot",
"data_integrity": "access_data_integrity",
"users": "access_users",
"permissions_direct": "access_permissions_direct",
"auth_guard": "access_auth_guard",
"api_keys": "access_api_keys",
"audit_logs": "access_audit_logs",
}
ch := make(chan checkResult, len(menus))
for m, rel := range menus {
go func(menuName, relation string) {
allowed, _ := h.KetoService.CheckPermission(ctx, subject, "System", "system", relation)
ch <- checkResult{menu: menuName, allowed: allowed}
}(m, rel)
}
for range menus {
res := <-ch
switch res.menu {
case "overview":
sp.Overview = res.allowed
case "tenants":
sp.Tenants = res.allowed
case "org_chart":
sp.OrgChart = res.allowed
case "worksmobile":
sp.Worksmobile = res.allowed
case "ory_ssot":
sp.OrySSOT = res.allowed
case "data_integrity":
sp.DataIntegrity = res.allowed
case "users":
sp.Users = res.allowed
case "permissions_direct":
sp.PermissionsDirect = res.allowed
case "auth_guard":
sp.AuthGuard = res.allowed
case "api_keys":
sp.ApiKeys = res.allowed
case "audit_logs":
sp.AuditLogs = res.allowed
}
}
}
profile.SystemPermissions = &sp
}
return profile
}

View File

@@ -3371,3 +3371,154 @@ func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error {
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
allowedRelations := map[string]bool{
"overview_viewers": true,
"tenants_viewers": true,
"org_chart_viewers": true,
"worksmobile_viewers": true,
"ory_ssot_viewers": true,
"data_integrity_viewers": true,
"users_viewers": true,
"permissions_direct_viewers": true,
"auth_guard_viewers": true,
"api_keys_viewers": true,
"audit_logs_viewers": true,
}
type userRelationInfo struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
}
userMap := make(map[string][]string)
for _, rel := range relations {
if !allowedRelations[rel.Relation] {
continue
}
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
userMap[userID] = append(userMap[userID], rel.Relation)
}
items := []userRelationInfo{}
for userID, rels := range userMap {
name := "Unknown"
email := "Unknown"
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
}
}
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
items = append(items, userRelationInfo{
UserID: userID,
Name: name,
Email: email,
Relations: rels,
})
}
return c.JSON(fiber.Map{
"items": items,
})
}
func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error {
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
allowedRelations := map[string]bool{
"overview_viewers": true,
"tenants_viewers": true,
"org_chart_viewers": true,
"worksmobile_viewers": true,
"ory_ssot_viewers": true,
"data_integrity_viewers": true,
"users_viewers": true,
"permissions_direct_viewers": true,
"auth_guard_viewers": true,
"api_keys_viewers": true,
"audit_logs_viewers": true,
}
if !allowedRelations[req.Relation] {
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "system",
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error {
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "system",
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionDelete,
})
}
return c.SendStatus(fiber.StatusOK)
}

View File

@@ -167,3 +167,103 @@ func TestTenantHandler_Relations(t *testing.T) {
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
})
}
func TestTenantHandler_SystemRelations(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate outbox: %v", err)
}
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
realOutbox := repository.NewKetoOutboxRepository(db)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
KetoOutbox: realOutbox,
}
userID := "user-system-1"
t.Run("ListSystemRelations - Returns correct system relations", func(t *testing.T) {
app := fiber.New()
app.Get("/system/relations", h.ListSystemRelations)
mockKeto.On("ListRelations", mock.Anything, "System", "system", "", "").Return([]service.RelationTuple{
{
Namespace: "System",
Object: "system",
Relation: "ory_ssot_viewers",
SubjectID: "User:" + userID,
},
{
Namespace: "System",
Object: "system",
Relation: "audit_logs_viewers",
SubjectID: "User:" + userID,
},
}, nil).Once()
req := httptest.NewRequest("GET", "/system/relations", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got struct {
Items []struct {
UserID string `json:"userId"`
Relations []string `json:"relations"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Len(t, got.Items, 1)
assert.Equal(t, userID, got.Items[0].UserID)
assert.Contains(t, got.Items[0].Relations, "ory_ssot_viewers")
assert.Contains(t, got.Items[0].Relations, "audit_logs_viewers")
mockKeto.AssertExpectations(t)
})
t.Run("AddSystemRelation - Inserts into KetoOutbox DB table with System namespace", func(t *testing.T) {
app := fiber.New()
app.Post("/system/relations", h.AddSystemRelation)
mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "ory_ssot_viewers",
})
req := httptest.NewRequest("POST", "/system/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", "system", "ory_ssot_viewers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "System", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
mockKeto.AssertExpectations(t)
})
}