1
0
forked from baron/baron-sso

테넌트 소유자, 관리자 분리

This commit is contained in:
2026-03-03 12:38:27 +09:00
parent 7bb1f3f702
commit 86ef9c6f60
23 changed files with 1091 additions and 516 deletions

View File

@@ -3925,104 +3925,83 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
}
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
slog.Info("🚨 [FATAL_DEBUG] ENVIRONMENT CHECK",
"APP_ENV", os.Getenv("APP_ENV"),
"GO_ENV", os.Getenv("GO_ENV"),
"X-Test-Role", c.Get("X-Test-Role"),
)
slog.Info("🚀 [TRACE] resolveCurrentProfile entry", "path", c.Path(), "method", c.Method())
// [Dev Only] Mock Role Bypass
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
mockRole := c.Get("X-Test-Role")
if mockRole == "" {
mockRole = c.Get("X-Mock-Role")
}
// Always log in development to see what's happening
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
slog.Info("🔍 [AUTH_DEBUG] Checking mock role",
"env", appEnv,
"mockRole", mockRole,
"X-Test-Role", c.Get("X-Test-Role"),
"X-Mock-Role", c.Get("X-Mock-Role"),
)
token := h.getBearerToken(c)
cookie := c.Get("Cookie")
var profile *domain.UserProfileResponse
var err error
cacheKey := ""
// 1. Try to fetch real profile if token/cookie exists
if token != "" || cookie != "" {
// Try Redis Cache
if h.RedisService != nil && token != "" {
cacheKey = "cache:profile:token:" + token
cached, _ := h.RedisService.Get(cacheKey)
if cached != "" {
if json.Unmarshal([]byte(cached), &profile) == nil {
// Fall through to role override check
}
}
}
if profile == nil {
// Fetch from Kratos (SoT)
if token != "" {
profile, err = h.getKratosProfile(token)
if err != nil && h.Hydra != nil {
// Fallback to Hydra introspection
profile, err = h.getHydraProfile(c.Context(), token)
}
} else if cookie != "" {
profile, err = h.getKratosProfileWithCookie(cookie)
}
}
}
// If in dev mode and we have a mock role, bypass Kratos
if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" {
slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole)
mockProfile := &domain.UserProfileResponse{
// 2. Role Override for real profile or fallback to Mock Profile
if profile != nil {
if isDev && mockRole != "" {
slog.Info("🔑 [AUTH_DEBUG] Overriding real profile role with mock role",
"email", profile.Email, "oldRole", profile.Role, "newRole", mockRole)
profile.Role = mockRole
}
} else if isDev && mockRole != "" {
slog.Info("🔑 [AUTH_DEBUG] No real session found, using full Mock Auth", "role", mockRole)
profile = &domain.UserProfileResponse{
ID: "00000000-0000-0000-0000-000000000000",
Email: "mock@hmac.kr",
Name: "Dev Mock User",
Role: mockRole,
}
if tid := c.Get("X-Tenant-ID"); tid != "" {
mockProfile.TenantID = &tid
}
return mockProfile, nil
}
// Mock bypass failed - log headers for debugging if in dev
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
slog.Warn("⚠️ [DEBUG] Mock auth bypass failed",
"appEnv", appEnv,
"X-Test-Role", c.Get("X-Test-Role"),
"X-Mock-Role", c.Get("X-Mock-Role"),
"path", c.Path())
}
var profile *domain.UserProfileResponse
var err error
token := h.getBearerToken(c)
cookie := c.Get("Cookie")
cacheKey := ""
// 1. Try Redis Cache
if h.RedisService != nil {
if token != "" {
cacheKey = "cache:profile:token:" + token
}
// Cookie based caching skipped for simplicity/safety
if cacheKey != "" {
cached, _ := h.RedisService.Get(cacheKey)
if cached != "" {
if json.Unmarshal([]byte(cached), &profile) == nil {
return profile, nil
}
}
profile.TenantID = &tid
}
}
// 2. Fetch from Kratos (SoT)
if token != "" {
profile, err = h.getKratosProfile(token)
} else {
if cookie != "" {
profile, err = h.getKratosProfileWithCookie(cookie)
}
}
if err != nil || profile == nil {
if profile == nil {
return nil, errors.New("invalid session (trace:resolve_profile)")
}
// 3. Post-Process (Defaults & Metadata Enrichment)
// Default Role if missing (migration safety)
if profile.Role == "" {
profile.Role = domain.RoleUser
}
// Fetch Tenant Metadata if missing
// Case A: Have TenantID from Kratos -> Fetch by ID
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
profile.Tenant = tenant
}
}
// Case B: Have CompanyCode but no TenantID -> Fetch by Slug
if profile.Tenant == nil && profile.CompanyCode != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
profile.Tenant = tenant
@@ -4033,7 +4012,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
}
// 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" {
if h.RedisService != nil && cacheKey != "" && err == nil {
if data, err := json.Marshal(profile); err == nil {
ttlStr := os.Getenv("PROFILE_CACHE_TTL")
ttl := 30 * time.Minute // Default TTL
@@ -5060,12 +5039,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]
return nil
}
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken)
func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
intro, err := h.Hydra.IntrospectToken(ctx, token)
if err != nil {
slog.Error("Hydra introspection failed", "error", err)
return nil, err
}
if !intro.Active {
slog.Warn("Hydra token is not active")
return nil, errors.New("token is not active")
}
slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)
// Fetch identity details from Kratos by subject (identityID)
identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject)
if err != nil || identity == nil {
slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject)
// Fallback to minimal profile if Kratos identity not found
return &domain.UserProfileResponse{
ID: intro.Subject,
Email: "unknown@hydra.local",
Name: "Hydra User",
Role: domain.RoleUser,
}, nil
}
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
}
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse {
email, _ := traits["email"].(string)
name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string)
@@ -5101,8 +5104,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
profile.Metadata[k] = v
}
}
return profile
}
return profile, nil
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
}
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
@@ -5110,44 +5120,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
if err != nil {
return nil, err
}
email, _ := traits["email"].(string)
name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string)
dept, _ := traits["department"].(string)
affType, _ := traits["affiliationType"].(string)
compCode, _ := traits["companyCode"].(string)
role, _ := traits["role"].(string)
tenantID, _ := traits["tenant_id"].(string)
profile := &domain.UserProfileResponse{
ID: identityID,
Email: email,
Name: name,
Phone: h.formatPhoneForDisplay(phone),
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
Role: role,
Metadata: make(map[string]any),
}
if tenantID != "" {
profile.TenantID = &tenantID
}
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true,
}
for k, v := range traits {
if !coreTraits[k] {
profile.Metadata[k] = v
}
}
return profile, nil
return h.mapKratosIdentityToProfile(identityID, traits), nil
}
// UpdateMe - Updates current user's profile with phone verification check

View File

@@ -140,7 +140,7 @@ type AsyncMockTenantService struct {
mock.Mock
}
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
return nil, nil
}

View File

@@ -221,7 +221,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid
}
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID)
// Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = profile.ID
}
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
return errorJSON(c, fiber.StatusConflict, err.Error())
@@ -423,20 +429,28 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details from Kratos
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err != nil {
admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"})
continue
}
// Fetch user details - Try Kratos first, then local DB
name := "Unknown"
email := "Unknown"
name := ""
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
email := ""
if e, ok := identity.Traits["email"].(string); ok {
email = e
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
}
} else if h.UserRepo != nil {
// Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos)
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"
}
}
admins = append(admins, adminInfo{
@@ -464,6 +478,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
// Also add as member for UI visibility/ReBAC logic
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
@@ -489,6 +511,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) ListOwners(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
// Fetch owners from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
type ownerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
owners := []ownerInfo{}
for _, rel := range relations {
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details - Try Kratos first, then local DB
name := "Unknown"
email := "Unknown"
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
}
} else if h.UserRepo != nil {
// Fallback to local DB
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"
}
}
owners = append(owners, ownerInfo{
ID: userID,
Name: name,
Email: email,
})
}
return c.JSON(owners)
}
func (h *TenantHandler) AddOwner(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "owners",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
// Also add as member for UI visibility/ReBAC logic
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "owners",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
return c.SendStatus(fiber.StatusNoContent)
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {

View File

@@ -21,8 +21,8 @@ type MockTenantService struct {
mock.Mock
}
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID)
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
@@ -133,7 +133,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
}
body, _ := json.Marshal(input)
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)).
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil), "").
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))