forked from baron/baron-sso
adminfront 및 백엔드: 글로벌 사이드바 11개 전 메뉴별 ReBAC 기반 접근 제어(Admin Control) 스키마, REST API, UI 설정 패널 전격 구현 완료
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user