forked from baron/baron-sso
테넌트 소유자, 관리자 분리
This commit is contained in:
@@ -566,6 +566,9 @@ func main() {
|
||||
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
||||
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||
admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners)
|
||||
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
||||
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
|
||||
|
||||
@@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil)
|
||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||
return err
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -59,8 +59,8 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
c.Locals("tenant_id", objectID)
|
||||
}
|
||||
|
||||
// Check with Keto
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||
// Check with Keto - add User: prefix to subject
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation)
|
||||
if err != nil {
|
||||
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
|
||||
@@ -596,3 +596,42 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
||||
|
||||
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
|
||||
}
|
||||
|
||||
type HydraIntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Subject string `json:"sub"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Ext map[string]interface{} `json:"ext"`
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/"))
|
||||
form := url.Values{}
|
||||
form.Set("token", token)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("hydra admin: introspection failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res HydraIntrospectionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type TenantService interface {
|
||||
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error)
|
||||
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error)
|
||||
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||
@@ -90,7 +90,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
@@ -119,15 +119,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Sync hierarchy via Outbox if ParentID exists
|
||||
if s.outboxRepo != nil && tenant.ParentID != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *tenant.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// [Keto] Sync hierarchy and ownership via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
// Sync hierarchy
|
||||
if tenant.ParentID != nil {
|
||||
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *tenant.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
}); err != nil {
|
||||
slog.Error("Failed to create outbox entry for tenant hierarchy", "tenant", tenant.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync creator ownership
|
||||
if creatorID != "" {
|
||||
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
|
||||
// Add as owner
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// Add as admin
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// Add as member
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||
@@ -187,12 +221,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
// [Keto] Sync relation via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||
slog.Info("Queueing tenant admin sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||
slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||
// Check if user already exists in our Read-Model
|
||||
if s.userRepo != nil {
|
||||
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
||||
if err == nil && user != nil {
|
||||
// User exists, assign Admin role in Keto via Outbox
|
||||
// User exists, assign Admin, Owner, and Member roles in Keto via Outbox
|
||||
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + user.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -200,6 +242,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
Subject: "User:" + user.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + user.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else {
|
||||
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) {
|
||||
// Mock: slug already exists
|
||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil)
|
||||
|
||||
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil)
|
||||
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
assert.Nil(t, tenant)
|
||||
@@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Case 1: Too short
|
||||
_, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil)
|
||||
_, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil, "")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Case 2: Invalid characters
|
||||
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil)
|
||||
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil, "")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,13 +162,54 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
|
||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
|
||||
|
||||
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil)
|
||||
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil, "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tenant)
|
||||
assert.Equal(t, "t1", tenant.ID)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_RegisterTenant_WithCreator(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
svc := NewTenantService(mockRepo, nil, mockOutbox)
|
||||
|
||||
ctx := context.Background()
|
||||
name := "Creator Tenant"
|
||||
slug := "creator-tenant"
|
||||
creatorID := "creator-uuid"
|
||||
tenantID := "t-new"
|
||||
|
||||
mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once()
|
||||
mockRepo.On("Create", ctx, mock.MatchedBy(func(t *domain.Tenant) bool {
|
||||
return t.Slug == slug
|
||||
})).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*domain.Tenant)
|
||||
t.ID = tenantID
|
||||
}).Return(nil)
|
||||
|
||||
// Expect owners sync
|
||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+creatorID
|
||||
})).Return(nil)
|
||||
// Expect admins sync
|
||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+creatorID
|
||||
})).Return(nil)
|
||||
// Expect members sync
|
||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+creatorID
|
||||
})).Return(nil)
|
||||
|
||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: tenantID, Slug: slug}, nil).Once()
|
||||
|
||||
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", nil, nil, creatorID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tenant)
|
||||
mockRepo.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
@@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
||||
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
||||
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
|
||||
// Now using Outbox instead of direct Keto call
|
||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+userID
|
||||
})).Return(nil)
|
||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
|
||||
})).Return(nil)
|
||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil)
|
||||
|
||||
err := svc.ApproveTenant(ctx, tenantID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
Reference in New Issue
Block a user