forked from baron/baron-sso
adminfront 및 백엔드: ReBAC 기반 각 탭별 읽기/쓰기 권한 제어 구현
This commit is contained in:
@@ -84,20 +84,27 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
||||
h.Worksmobile = syncer
|
||||
}
|
||||
|
||||
type tenantPermissions struct {
|
||||
View bool `json:"view"`
|
||||
Manage bool `json:"manage"`
|
||||
ManageAdmins bool `json:"manage_admins"`
|
||||
}
|
||||
|
||||
type tenantSummary struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type tenantListResponse struct {
|
||||
@@ -1678,6 +1685,53 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
summary.MemberCount = memberCounts[tenant.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||
|
||||
// Populate Keto-based permissions for the current user
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if ok && profile != nil {
|
||||
role := domain.NormalizeRole(profile.Role)
|
||||
if role == domain.RoleSuperAdmin {
|
||||
summary.UserPermissions = &tenantPermissions{
|
||||
View: true,
|
||||
Manage: true,
|
||||
ManageAdmins: true,
|
||||
}
|
||||
} else {
|
||||
// Query Keto in parallel for maximum performance
|
||||
subject := "User:" + profile.ID
|
||||
type checkResult struct {
|
||||
relation string
|
||||
allowed bool
|
||||
err error
|
||||
}
|
||||
ch := make(chan checkResult, 3)
|
||||
relations := []string{"view", "manage", "manage_admins"}
|
||||
for _, rel := range relations {
|
||||
go func(r string) {
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r)
|
||||
ch <- checkResult{relation: r, allowed: allowed, err: err}
|
||||
}(rel)
|
||||
}
|
||||
|
||||
perms := &tenantPermissions{}
|
||||
for range relations {
|
||||
res := <-ch
|
||||
if res.err != nil {
|
||||
slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID)
|
||||
continue
|
||||
}
|
||||
switch res.relation {
|
||||
case "view":
|
||||
perms.View = res.allowed
|
||||
case "manage":
|
||||
perms.Manage = res.allowed
|
||||
case "manage_admins":
|
||||
perms.ManageAdmins = res.allowed
|
||||
}
|
||||
}
|
||||
summary.UserPermissions = perms
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(summary)
|
||||
}
|
||||
|
||||
|
||||
164
backend/internal/handler/tenant_handler_get_test.go
Normal file
164
backend/internal/handler/tenant_handler_get_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestTenantHandler_GetTenant_SuperAdmin(t *testing.T) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||
}
|
||||
|
||||
// Create a test tenant in DB with a valid UUID
|
||||
tenant := domain.Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000010",
|
||||
Name: "Super Admin Test Tenant",
|
||||
Slug: "super-admin-test-tenant",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := db.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
// Mock projection status
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil)
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000010": 5}, nil)
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000010": 5}, nil)
|
||||
|
||||
h := &TenantHandler{
|
||||
DB: db,
|
||||
Service: mockSvc,
|
||||
Keto: mockKeto,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
// We'll simulate middleware setting "user_profile" for a Super Admin
|
||||
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-super-admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
}
|
||||
c.Locals("user_profile", profile)
|
||||
return h.GetTenant(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000010", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got tenantSummary
|
||||
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "00000000-0000-0000-0000-000000000010", got.ID)
|
||||
assert.Equal(t, "Super Admin Test Tenant", got.Name)
|
||||
assert.NotNil(t, got.UserPermissions)
|
||||
assert.True(t, got.UserPermissions.View)
|
||||
assert.True(t, got.UserPermissions.Manage)
|
||||
assert.True(t, got.UserPermissions.ManageAdmins)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||
}
|
||||
|
||||
// Create a test tenant in DB with a valid UUID
|
||||
tenant := domain.Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000020",
|
||||
Name: "Normal User Test Tenant",
|
||||
Slug: "normal-user-test-tenant",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := db.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
// Mock projection status
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil)
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000020": 2}, nil)
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000020": 2}, nil)
|
||||
|
||||
h := &TenantHandler{
|
||||
DB: db,
|
||||
Service: mockSvc,
|
||||
Keto: mockKeto,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
// Mock Keto response: allowed view/manage but not manage_admins
|
||||
subject := "User:user-normal-id"
|
||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
|
||||
|
||||
// We'll simulate middleware setting "user_profile" for a regular admin/user
|
||||
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-normal-id",
|
||||
Role: domain.RoleUser,
|
||||
}
|
||||
c.Locals("user_profile", profile)
|
||||
return h.GetTenant(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000020", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got tenantSummary
|
||||
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "00000000-0000-0000-0000-000000000020", got.ID)
|
||||
assert.Equal(t, "Normal User Test Tenant", got.Name)
|
||||
assert.NotNil(t, got.UserPermissions)
|
||||
assert.True(t, got.UserPermissions.View)
|
||||
assert.True(t, got.UserPermissions.Manage)
|
||||
assert.False(t, got.UserPermissions.ManageAdmins)
|
||||
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
Reference in New Issue
Block a user