1
0
forked from baron/baron-sso

테넌트 목록 및 조직 계층 구조 개선

This commit is contained in:
2026-02-27 10:29:15 +09:00
parent 600961f33d
commit ca45a14bae
27 changed files with 1906 additions and 806 deletions

View File

@@ -102,6 +102,15 @@ func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search
return nil, 0, nil
}
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
return 0, nil
}
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
type AsyncMockRedisRepo struct {
mock.Mock
}
@@ -128,7 +137,7 @@ type AsyncMockTenantService struct {
mock.Mock
}
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, 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) (*domain.Tenant, error) {
return nil, nil
}

View File

@@ -6,6 +6,7 @@ import (
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"errors"
"log/slog"
"strings"
"time"
@@ -16,15 +17,17 @@ import (
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
UserRepo: userRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
@@ -33,12 +36,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
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"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
@@ -98,6 +104,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
if limit <= 0 {
limit = 50
}
@@ -105,19 +113,45 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset = 0
}
// Use separate queries for count and find to avoid GORM statement contamination
countQuery := h.DB.Model(&domain.Tenant{})
if parentId != "" {
countQuery = countQuery.Where("parent_id = ?", parentId)
}
var total int64
if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil {
if err := countQuery.Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
findQuery := h.DB.Model(&domain.Tenant{})
if parentId != "" {
findQuery = findQuery.Where("parent_id = ?", parentId)
}
var tenants []domain.Tenant
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Fetch member counts for all tenants in one query using slugs (company codes)
slugs := make([]string, 0, len(tenants))
for _, t := range tenants {
slugs = append(slugs, t.Slug)
}
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
if err != nil {
slog.Warn("failed to count members for tenants", "error", err)
memberCounts = make(map[string]int64)
}
items := make([]tenantSummary, 0, len(tenants))
for _, t := range tenants {
items = append(items, mapTenantSummary(t))
summary := mapTenantSummary(t)
// Ensure robust matching by trimming and lowercasing the slug key
key := strings.ToLower(strings.TrimSpace(t.Slug))
summary.MemberCount = memberCounts[key]
items = append(items, summary)
}
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
@@ -141,7 +175,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(mapTenantSummary(tenant))
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
count := int64(0)
if err == nil {
count = memberCounts[strings.ToLower(tenant.Slug)]
}
summary := mapTenantSummary(tenant)
summary.MemberCount = count
return c.JSON(summary)
}
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
@@ -152,6 +194,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains"`
@@ -167,6 +210,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
}
tenantType := normalizeTenantType(req.Type)
if tenantType == "" {
tenantType = domain.TenantTypeCompany // Default to COMPANY
}
slug := req.Slug
if slug == "" {
slug = utils.GenerateUniqueSlug(name, func(s string) bool {
@@ -193,7 +241,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid
}
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains, parentID)
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
@@ -201,12 +249,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
summary := mapTenantSummary(*tenant)
summary.MemberCount = 0
if req.Config != nil {
tenant.Config = req.Config
h.DB.Save(tenant)
summary.Config = tenant.Config
}
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant))
return c.Status(fiber.StatusCreated).JSON(summary)
}
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
@@ -229,9 +281,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
var req struct {
Name *string `json:"name"`
Type *string `json:"type"`
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
ParentID *string `json:"parentId"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
}
@@ -246,6 +300,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
tenant.Name = name
}
if req.Type != nil {
tenantType := normalizeTenantType(*req.Type)
if tenantType == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"})
}
tenant.Type = tenantType
}
if req.Slug != nil {
slug := utils.GenerateSlug(*req.Slug)
if slug == "" {
@@ -271,6 +332,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
tenant.Status = status
}
if req.ParentID != nil {
pid := strings.TrimSpace(*req.ParentID)
if pid == "" {
tenant.ParentID = nil
} else {
tenant.ParentID = &pid
}
// [Keto] Sync hierarchy via Outbox
if h.KetoOutbox != nil {
if tenant.ParentID != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
})
} else {
// We don't have enough info here to delete specific parent if we don't know the old one,
// but for now we focus on adding.
}
}
}
if req.Config != nil {
tenant.Config = req.Config
}
@@ -432,6 +517,8 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
return tenantSummary{
ID: t.ID,
Type: t.Type,
ParentID: t.ParentID,
Name: t.Name,
Slug: t.Slug,
Description: t.Description,
@@ -453,3 +540,13 @@ func normalizeTenantStatus(value string) string {
}
return value
}
func normalizeTenantType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
switch value {
case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup:
return value
default:
return ""
}
}

View File

@@ -21,8 +21,8 @@ type MockTenantService struct {
mock.Mock
}
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
args := m.Called(ctx, name, slug, description, domains, parentID)
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)
if args.Get(0) == nil {
return nil, args.Error(1)
}
@@ -85,7 +85,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
}
body, _ := json.Marshal(input)
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []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))

View File

@@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if limit <= 0 {
limit = 50
@@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// Tenant Admin filtering
if requesterRole == domain.RoleTenantAdmin {
if requesterCompany == "" || compCode != requesterCompany {
if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) {
continue
}
}
// Search filtering
// Dedicated companyCode filter
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
continue
}
// Search filtering (Keyword search in email, name, or companyCode)
if search != "" {
if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) {
if !strings.Contains(email, searchLower) &&
!strings.Contains(name, searchLower) &&
!strings.Contains(strings.ToLower(compCode), searchLower) {
continue
}
}
@@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
items = append(items, summary)
}
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
// This ensures that member counts (which use local DB) eventually match reality
if h.UserRepo != nil {
go func(ids []service.KratosIdentity) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, identity := range ids {
localUser := h.mapToLocalUser(identity)
_ = h.UserRepo.Update(ctx, localUser)
}
}(filtered)
}
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
}
// 2. Fallback to Local DB if Kratos is down (Development only recommended)
// 2. Fallback to Local DB if Kratos is down
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
// Fetch from UserRepo
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search)
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"})
}
@@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// [New] Local DB Sync
localUser := &domain.User{
ID: identityID,
Email: email,
Name: name,
Phone: normalizePhoneNumber(req.Phone),
AffiliationType: "internal",
CompanyCode: req.CompanyCode,
Department: req.Department,
Role: role,
Status: "active",
Metadata: req.Metadata,
}
if tenantID != "" {
localUser.TenantID = &tenantID
}
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
if h.UserRepo != nil {
go func(u *domain.User) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.UserRepo.Create(ctx, u); err != nil {
slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err)
}
}(localUser)
}
// [Keto] Sync relations via Outbox
if h.KetoOutboxRepo != nil {
// 1. Tenant Membership
if localUser.TenantID != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "members",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
}
// 2. Role Specifics
if role == domain.RoleSuperAdmin {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "admins",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
}
}
// Fetch the newly created identity to ensure we have all traits
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
}
// [New] Local DB Sync - Ensure user exists in read-model
if h.UserRepo != nil {
localUser := h.mapToLocalUser(*identity)
// Sync to local DB
go func(u *domain.User, role string, tID *string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Use Update (upsert) instead of Create for robustness
if err := h.UserRepo.Update(ctx, u); err != nil {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
return
}
// [Keto] Sync relations via Outbox
if h.KetoOutboxRepo != nil {
h.syncKetoRole(ctx, u.ID, role, "", "", tID)
}
}(localUser, role, localUser.TenantID)
}
response := h.mapIdentitySummary(c.Context(), *identity)
if generatedPassword != "" {
response.InitialPassword = generatedPassword
@@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
// Capture current local state for transition comparison
var oldRole string
var oldTenantID string
if h.UserRepo != nil {
if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
oldRole = local.Role
if local.TenantID != nil {
oldTenantID = *local.TenantID
}
}
}
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && requester.Role == domain.RoleTenantAdmin {
@@ -420,7 +416,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["name"] = strings.TrimSpace(*req.Name)
}
if req.Phone != nil {
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone))
if phone == "" {
delete(traits, "phone_number")
} else {
traits["phone_number"] = phone
}
}
if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode)
@@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// [New] Local DB Sync
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
if h.UserRepo != nil {
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
oldRole := localUser.Role
oldTenantID := ""
if localUser.TenantID != nil {
oldTenantID = *localUser.TenantID
}
if req.Name != nil {
localUser.Name = *req.Name
}
if req.Phone != nil {
localUser.Phone = normalizePhoneNumber(*req.Phone)
}
if req.CompanyCode != nil {
localUser.CompanyCode = *req.CompanyCode
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
localUser.TenantID = &tenant.ID
}
}
if req.Department != nil {
localUser.Department = *req.Department
}
if req.Role != nil {
localUser.Role = *req.Role
}
if req.Status != nil {
localUser.Status = *req.Status
}
if req.Metadata != nil {
localUser.Metadata = req.Metadata
}
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.UserRepo.Update(ctx, u); err == nil {
// [Keto Sync on Role Change] via Outbox
if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
uID := u.ID
newR := *rRole
if oRole == domain.RoleSuperAdmin {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionDelete,
})
} else if oRole == domain.RoleTenantAdmin && oTenantID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: oTenantID,
Relation: "admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionDelete,
})
}
if newR == domain.RoleSuperAdmin {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionCreate,
})
} else if newR == domain.RoleTenantAdmin && u.TenantID != nil {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionCreate,
})
}
}
} else {
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err)
}
}(localUser, req.Role, oldRole, oldTenantID)
updatedLocalUser := h.mapToLocalUser(*updated)
ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
}
// [Keto Sync] asynchronously as it's less critical for immediate UI count
go h.syncKetoRole(context.Background(), updatedLocalUser.ID,
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
}
if req.Password != nil && *req.Password != "" {
@@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
return summary
}
func (h *UserHandler) normalizePhoneNumber(phone string) string {
return normalizePhoneNumber(phone)
}
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
traits := identity.Traits
role := extractTraitString(traits, "grade")
if role == "" {
role = "user"
}
compCode := extractTraitString(traits, "companyCode")
user := &domain.User{
ID: identity.ID,
Email: extractTraitString(traits, "email"),
Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
AffiliationType: extractTraitString(traits, "affiliationType"),
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
if compCode != "" && h.TenantService != nil {
// Use a background context or a timeout-limited context for tenant lookup
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
user.TenantID = &tenant.ID
}
}
// Metadata
user.Metadata = make(domain.JSONMap)
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] {
user.Metadata[k] = v
}
}
return user
}
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
// Remove old roles
if oldRole == domain.RoleSuperAdmin {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: oldTenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
// Add new roles
if newRole == domain.RoleSuperAdmin {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *newTenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
}
func extractTraitString(traits map[string]interface{}, key string) string {
if traits == nil {
return ""