forked from baron/baron-sso
테넌트 소유자, 관리자 분리
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user