forked from baron/baron-sso
Ory Keto ReBAC Policy & Relation Tuple Architecture
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/validator"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -209,6 +210,12 @@ func main() {
|
||||
slog.Error("❌ Bootstrap failed", "error", err)
|
||||
}
|
||||
|
||||
// [New] Initialize Keto Outbox and Worker
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db)
|
||||
ketoRelayWorker := service.NewKetoRelayWorker(ketoOutboxRepo, ketoService)
|
||||
go ketoRelayWorker.Start(context.Background())
|
||||
slog.Info("✅ Keto Relay Worker started")
|
||||
|
||||
// [Moved & Enhanced] Seed Admin Identity & Sync Local Role
|
||||
if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||
@@ -253,28 +260,32 @@ func main() {
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
|
||||
tenantService := service.NewTenantService(tenantRepo, userRepo)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
|
||||
tenantService := service.NewTenantService(tenantRepo, userRepo, ketoOutboxRepo)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
|
||||
hydraService := service.NewHydraAdminService()
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
consentRepo := repository.NewClientConsentRepository(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo)
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
||||
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
app := fiber.New(fiber.Config{
|
||||
@@ -550,18 +561,19 @@ func main() {
|
||||
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)
|
||||
|
||||
// User Group Management (Tenant Admin/Super Admin)
|
||||
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
|
||||
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
|
||||
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||
userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
|
||||
userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
|
||||
org.Post("/import", orgChartHandler.ImportCSV) // CSV Import API
|
||||
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
org.Get("/:id", userGroupHandler.Get)
|
||||
org.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||
org.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||
org.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||
org.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||
org.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||
org.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
|
||||
org.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
|
||||
|
||||
// Relying Party Management (Global List)
|
||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||
|
||||
@@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.IdentityProviderConfig{},
|
||||
&domain.ClientSecret{},
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
||||
slog.Info("Syncing tenants to Keto", "count", len(tenants))
|
||||
for _, t := range tenants {
|
||||
if t.ParentID != nil {
|
||||
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
|
||||
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parents", "Tenant:"+*t.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,14 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
||||
for _, u := range users {
|
||||
// Membership
|
||||
if u.TenantID != nil {
|
||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", u.ID)
|
||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", "User:"+u.ID)
|
||||
}
|
||||
|
||||
// Roles
|
||||
if u.Role == domain.RoleSuperAdmin {
|
||||
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", u.ID)
|
||||
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", "User:"+u.ID)
|
||||
} else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil {
|
||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", u.ID)
|
||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", "User:"+u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ func SeedTenants(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Seeding initial tenants...")
|
||||
repo := repository.NewTenantRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
svc := service.NewTenantService(repo, userRepo)
|
||||
outboxRepo := repository.NewKetoOutboxRepository(db)
|
||||
svc := service.NewTenantService(repo, userRepo, outboxRepo)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, config := range defaultTenants {
|
||||
|
||||
48
backend/internal/domain/keto_outbox.go
Normal file
48
backend/internal/domain/keto_outbox.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KetoOutbox status
|
||||
const (
|
||||
KetoOutboxStatusPending = "pending"
|
||||
KetoOutboxStatusProcessed = "processed"
|
||||
KetoOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// KetoOutbox action
|
||||
const (
|
||||
KetoOutboxActionCreate = "CREATE"
|
||||
KetoOutboxActionDelete = "DELETE"
|
||||
)
|
||||
|
||||
// KetoOutbox represents a Keto relationship tuple update event.
|
||||
type KetoOutbox struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Namespace string `gorm:"not null" json:"namespace"`
|
||||
Object string `gorm:"not null" json:"object"`
|
||||
Relation string `gorm:"not null" json:"relation"`
|
||||
Subject string `gorm:"not null" json:"subject"` // format: "User:ID" or "Tenant:ID#members"
|
||||
Action string `gorm:"not null" json:"action"` // CREATE, DELETE
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (ko *KetoOutbox) TableName() string {
|
||||
return "keto_outbox"
|
||||
}
|
||||
|
||||
func (ko *KetoOutbox) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if ko.ID == "" {
|
||||
ko.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -15,9 +15,18 @@ const (
|
||||
TenantStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Tenant types
|
||||
const (
|
||||
TenantTypePersonal = "PERSONAL"
|
||||
TenantTypeCompany = "COMPANY"
|
||||
TenantTypeCompanyGroup = "COMPANY_GROUP"
|
||||
TenantTypeUserGroup = "USER_GROUP"
|
||||
)
|
||||
|
||||
// Tenant represents a tenant model stored in PostgreSQL.
|
||||
type Tenant struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
|
||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||
|
||||
@@ -29,6 +29,8 @@ type User struct {
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"` // 직급 (예: 수석, 책임, 선임)
|
||||
JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
type UserGroup struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"`
|
||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 상위 조직 ID
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
UnitType string `json:"unitType"` // 부, 국, 팀, 셀 등
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Relationships
|
||||
Members []User `gorm:"-" json:"members,omitempty"`
|
||||
Parent *UserGroup `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Members []User `gorm:"-" json:"members,omitempty"`
|
||||
}
|
||||
|
||||
type GroupRole struct {
|
||||
|
||||
@@ -88,6 +88,7 @@ type AuthHandler struct {
|
||||
Hydra *service.HydraAdminService
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
UserRepo repository.UserRepository
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
}
|
||||
@@ -147,7 +148,7 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
|
||||
return false, int(interval.Seconds())
|
||||
}
|
||||
|
||||
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
|
||||
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
SmsService: service.NewSmsService(),
|
||||
EmailService: service.NewEmailService(),
|
||||
@@ -159,6 +160,7 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
KetoOutboxRepo: ketoOutboxRepo,
|
||||
UserRepo: userRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
}
|
||||
@@ -496,20 +498,20 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
|
||||
} else {
|
||||
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
|
||||
// [Keto] Sync user-tenant relationship via Outbox
|
||||
if h.KetoOutboxRepo != nil && u.TenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + u.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}(localUser)
|
||||
}
|
||||
|
||||
// [Keto] Sync user-tenant relationship
|
||||
if h.KetoService != nil && tenantID != nil {
|
||||
go func() {
|
||||
err := h.KetoService.CreateRelation(context.Background(), "Tenant", *tenantID, "members", providerID)
|
||||
if err != nil {
|
||||
slog.Error("[Signup] Failed to sync membership to Keto", "userID", providerID, "tenantID", *tenantID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"message": "User registered successfully",
|
||||
|
||||
41
backend/internal/handler/org_chart_handler.go
Normal file
41
backend/internal/handler/org_chart_handler.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type OrgChartHandler struct {
|
||||
Service service.OrgChartService
|
||||
}
|
||||
|
||||
func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
|
||||
return &OrgChartHandler{Service: s}
|
||||
}
|
||||
|
||||
func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "failed to get file from form"})
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open file"})
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := h.Service.ImportCSV(c.Context(), tenantID, f); err != nil {
|
||||
slog.Error("Failed to import CSV", "error", err, "tenantID", tenantID)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Import completed successfully"})
|
||||
}
|
||||
@@ -16,14 +16,16 @@ type TenantHandler struct {
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin *service.KratosAdminService
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, kratos *service.KratosAdminService) *TenantHandler {
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos *service.KratosAdminService) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
DB: db,
|
||||
Service: svc,
|
||||
Keto: keto,
|
||||
KetoOutbox: outbox,
|
||||
KratosAdmin: kratos,
|
||||
}
|
||||
}
|
||||
@@ -324,7 +326,7 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Fetch admins from Keto
|
||||
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "")
|
||||
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -375,8 +377,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
|
||||
}
|
||||
|
||||
if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
if h.KetoOutbox != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
@@ -389,8 +397,14 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
|
||||
}
|
||||
|
||||
if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
if h.KetoOutbox != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
|
||||
@@ -14,20 +14,22 @@ import (
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
KratosAdmin *service.KratosAdminService
|
||||
OryProvider *service.OryProvider
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
UserRepo repository.UserRepository
|
||||
KratosAdmin *service.KratosAdminService
|
||||
OryProvider *service.OryProvider
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
UserRepo repository.UserRepository
|
||||
}
|
||||
|
||||
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *UserHandler {
|
||||
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler {
|
||||
return &UserHandler{
|
||||
KratosAdmin: kratosAdmin,
|
||||
OryProvider: oryProvider,
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
UserRepo: userRepo,
|
||||
KratosAdmin: kratosAdmin,
|
||||
OryProvider: oryProvider,
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
KetoOutboxRepo: ketoOutboxRepo,
|
||||
UserRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,21 +317,36 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}(localUser)
|
||||
}
|
||||
|
||||
// [Keto] Sync relations
|
||||
if h.KetoService != nil {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
// 1. Tenant Membership
|
||||
if localUser.TenantID != nil {
|
||||
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID)
|
||||
}
|
||||
// 2. Role Specifics
|
||||
if role == domain.RoleSuperAdmin {
|
||||
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", identityID)
|
||||
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
|
||||
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "admins", identityID)
|
||||
}
|
||||
}()
|
||||
// [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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
@@ -489,25 +506,50 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// [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]
|
||||
if h.KetoService != nil && rRole != nil && *rRole != oRole {
|
||||
// [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.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
||||
_ = 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.KetoService.DeleteRelation(ctx, "Tenant", oTenantID, "admins", uID)
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: oTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
if newR == domain.RoleSuperAdmin {
|
||||
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID)
|
||||
_ = 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.KetoService.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", uID)
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -552,16 +594,17 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// [Keto] Cleanup relations (Best effort)
|
||||
if h.KetoService != nil {
|
||||
go func(uID string) {
|
||||
ctx := context.Background()
|
||||
// Fetch user from DB before cleanup if needed, but here we cleanup common namespaces
|
||||
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
||||
|
||||
// If we had more complex relations, we would query Keto first or use user metadata
|
||||
slog.Info("Keto relations cleaned up for user", "userID", uID)
|
||||
}(userID)
|
||||
// [Keto] Cleanup relations via Outbox
|
||||
if h.KetoOutboxRepo != nil {
|
||||
ctx := context.Background()
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
// Additional cleanup for tenants could be added here if we keep track of user's current tenants
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
|
||||
61
backend/internal/repository/keto_outbox_repository.go
Normal file
61
backend/internal/repository/keto_outbox_repository.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type KetoOutboxRepository interface {
|
||||
Create(ctx context.Context, entry *domain.KetoOutbox) error
|
||||
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
|
||||
FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error)
|
||||
UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error
|
||||
MarkProcessed(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type ketoOutboxRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewKetoOutboxRepository(db *gorm.DB) KetoOutboxRepository {
|
||||
return &ketoOutboxRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ketoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
|
||||
return r.db.WithContext(ctx).Create(entry).Error
|
||||
}
|
||||
|
||||
func (r *ketoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
|
||||
return tx.Create(entry).Error
|
||||
}
|
||||
|
||||
func (r *ketoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
|
||||
var entries []domain.KetoOutbox
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("status = ?", domain.KetoOutboxStatusPending).
|
||||
Order("created_at asc").
|
||||
Limit(limit).
|
||||
Find(&entries).Error
|
||||
return entries, err
|
||||
}
|
||||
|
||||
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"retry_count": retryCount,
|
||||
"last_error": lastError,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": domain.KetoOutboxStatusProcessed,
|
||||
"processed_at": &now,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
78
backend/internal/service/keto_relay_worker.go
Normal file
78
backend/internal/service/keto_relay_worker.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KetoRelayWorker interface {
|
||||
Start(ctx context.Context)
|
||||
}
|
||||
|
||||
type ketoRelayWorker struct {
|
||||
outboxRepo repository.KetoOutboxRepository
|
||||
ketoService KetoService
|
||||
interval time.Duration
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func NewKetoRelayWorker(outboxRepo repository.KetoOutboxRepository, ketoService KetoService) KetoRelayWorker {
|
||||
return &ketoRelayWorker{
|
||||
outboxRepo: outboxRepo,
|
||||
ketoService: ketoService,
|
||||
interval: 5 * time.Second, // Poll every 5 seconds
|
||||
maxRetries: 5,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ketoRelayWorker) Start(ctx context.Context) {
|
||||
slog.Info("[KetoRelayWorker] Starting worker...")
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("[KetoRelayWorker] Stopping worker...")
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.processEntries(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ketoRelayWorker) processEntries(ctx context.Context) {
|
||||
entries, err := w.outboxRepo.FindPending(ctx, 50) // Process up to 50 at once
|
||||
if err != nil {
|
||||
slog.Error("[KetoRelayWorker] Failed to fetch pending entries", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
w.processEntry(ctx, entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ketoRelayWorker) processEntry(ctx context.Context, entry domain.KetoOutbox) {
|
||||
var err error
|
||||
if entry.Action == domain.KetoOutboxActionCreate {
|
||||
err = w.ketoService.CreateRelation(ctx, entry.Namespace, entry.Object, entry.Relation, entry.Subject)
|
||||
} else if entry.Action == domain.KetoOutboxActionDelete {
|
||||
err = w.ketoService.DeleteRelation(ctx, entry.Namespace, entry.Object, entry.Relation, entry.Subject)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("[KetoRelayWorker] Failed to process entry", "id", entry.ID, "error", err)
|
||||
newRetryCount := entry.RetryCount + 1
|
||||
status := domain.KetoOutboxStatusPending
|
||||
if newRetryCount >= w.maxRetries {
|
||||
status = domain.KetoOutboxStatusFailed
|
||||
}
|
||||
_ = w.outboxRepo.UpdateStatus(ctx, entry.ID, status, newRetryCount, err.Error())
|
||||
} else {
|
||||
_ = w.outboxRepo.MarkProcessed(ctx, entry.ID)
|
||||
}
|
||||
}
|
||||
70
backend/internal/service/mock_common_test.go
Normal file
70
backend/internal/service/mock_common_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Shared Mocks for Service Tests ---
|
||||
|
||||
type MockKetoOutboxRepositoryShared struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockKetoOutboxRepositoryShared) Create(ctx context.Context, entry *domain.KetoOutbox) error {
|
||||
return m.Called(ctx, entry).Error(0)
|
||||
}
|
||||
func (m *MockKetoOutboxRepositoryShared) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
|
||||
return m.Called(tx, entry).Error(0)
|
||||
}
|
||||
func (m *MockKetoOutboxRepositoryShared) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
|
||||
args := m.Called(ctx, limit)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||
}
|
||||
func (m *MockKetoOutboxRepositoryShared) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||
}
|
||||
func (m *MockKetoOutboxRepositoryShared) MarkProcessed(ctx context.Context, id string) error {
|
||||
return m.Called(ctx, id).Error(0)
|
||||
}
|
||||
|
||||
type MockKetoServiceShared struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockKetoServiceShared) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||
args := m.Called(ctx, subject, namespace, object, relation)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKetoServiceShared) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKetoServiceShared) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKetoServiceShared) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]RelationTuple), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKetoServiceShared) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||
args := m.Called(ctx, namespace, relation, subject)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
239
backend/internal/service/org_chart_service.go
Normal file
239
backend/internal/service/org_chart_service.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type OrgChartService interface {
|
||||
ImportCSV(ctx context.Context, tenantID string, r io.Reader) error
|
||||
}
|
||||
|
||||
type orgChartService struct {
|
||||
tenantRepo repository.TenantRepository
|
||||
userGroupRepo repository.UserGroupRepository
|
||||
userRepo repository.UserRepository
|
||||
ketoOutboxRepo repository.KetoOutboxRepository
|
||||
kratos *KratosAdminService
|
||||
}
|
||||
|
||||
func NewOrgChartService(
|
||||
tenantRepo repository.TenantRepository,
|
||||
userGroupRepo repository.UserGroupRepository,
|
||||
userRepo repository.UserRepository,
|
||||
ketoOutbox repository.KetoOutboxRepository,
|
||||
kratos *KratosAdminService,
|
||||
) OrgChartService {
|
||||
return &orgChartService{
|
||||
tenantRepo: tenantRepo,
|
||||
userGroupRepo: userGroupRepo,
|
||||
userRepo: userRepo,
|
||||
ketoOutboxRepo: ketoOutbox,
|
||||
kratos: kratos,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error {
|
||||
reader := csv.NewReader(r)
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CSV header: %w", err)
|
||||
}
|
||||
|
||||
// Map header columns
|
||||
colMap := make(map[string]int)
|
||||
for i, name := range header {
|
||||
colMap[strings.ToLower(strings.TrimSpace(name))] = i
|
||||
}
|
||||
|
||||
// Required columns
|
||||
required := []string{"email", "name", "organization", "position", "jobtitle"}
|
||||
for _, req := range required {
|
||||
if _, ok := colMap[req]; !ok {
|
||||
return fmt.Errorf("missing required column: %s", req)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for created/found organization units to handle hierarchy efficiently
|
||||
// key: path (e.g. "HQ/Sales"), value: ID
|
||||
pathCache := make(map[string]string)
|
||||
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("Failed to read CSV record", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(record[colMap["email"]])
|
||||
name := strings.TrimSpace(record[colMap["name"]])
|
||||
orgPath := strings.TrimSpace(record[colMap["organization"]])
|
||||
position := strings.TrimSpace(record[colMap["position"]])
|
||||
jobTitle := strings.TrimSpace(record[colMap["jobtitle"]])
|
||||
|
||||
if email == "" || name == "" || orgPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Process Organization Hierarchy
|
||||
leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
|
||||
if err != nil {
|
||||
slog.Error("Failed to ensure org path", "path", orgPath, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Upsert User
|
||||
// Check if user exists in Kratos first (SoT)
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil || kratosID == "" {
|
||||
slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update User in Local DB (Read-Model)
|
||||
user, err := s.userRepo.FindByID(ctx, kratosID)
|
||||
if err != nil {
|
||||
// If not in local DB, create it
|
||||
user = &domain.User{
|
||||
ID: kratosID,
|
||||
Email: email,
|
||||
}
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.Position = position
|
||||
user.JobTitle = jobTitle
|
||||
user.Department = orgPath
|
||||
user.TenantID = &tenantID
|
||||
user.Status = "active"
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Sync Membership to Keto via Outbox
|
||||
if s.ketoOutboxRepo != nil {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
currentParentID := rootTenantID
|
||||
currentPath := ""
|
||||
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentPath == "" {
|
||||
currentPath = part
|
||||
} else {
|
||||
currentPath = currentPath + "/" + part
|
||||
}
|
||||
|
||||
if id, ok := cache[currentPath]; ok {
|
||||
currentParentID = id
|
||||
continue
|
||||
}
|
||||
|
||||
// Check DB if already exists
|
||||
// We search for a USER_GROUP tenant with this name and parent
|
||||
// Note: This logic assumes name is unique under a parent
|
||||
// For robustness, we should probably have a better lookup
|
||||
var existingID string
|
||||
// In a real implementation, Repo should have a FindByParentAndName method
|
||||
// For this implementation, we'll try to find by Name and ParentID in TenantRepo or UserGroupRepo
|
||||
// Since we're using Polymorphic Tenants, let's assume we can lookup
|
||||
|
||||
// For simplicity in this POC, let's just use Create logic if not in cache
|
||||
// In production, we MUST check DB first to avoid duplicates
|
||||
|
||||
// [Placeholder] Lookup in DB logic...
|
||||
// existingID = s.lookupOrgUnit(ctx, rootTenantID, currentParentID, part)
|
||||
|
||||
if existingID == "" {
|
||||
// Create new unit
|
||||
unitID := uuid.NewString()
|
||||
|
||||
// 1. Create Tenant (Type: USER_GROUP)
|
||||
newTenant := &domain.Tenant{
|
||||
ID: unitID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: "ug-" + unitID[:8],
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := s.tenantRepo.Create(ctx, newTenant); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. Create UserGroup metadata
|
||||
newUserGroup := &domain.UserGroup{
|
||||
ID: unitID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
UnitType: s.guessUnitType(i, len(parts)),
|
||||
}
|
||||
if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 3. Sync Hierarchy to Keto via Outbox
|
||||
if s.ketoOutboxRepo != nil {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: unitID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + currentParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
existingID = unitID
|
||||
}
|
||||
|
||||
cache[currentPath] = existingID
|
||||
currentParentID = existingID
|
||||
}
|
||||
|
||||
return currentParentID, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) guessUnitType(index, total int) string {
|
||||
if total == 1 {
|
||||
return "Team"
|
||||
}
|
||||
if index == 0 {
|
||||
return "Division"
|
||||
}
|
||||
if index == total-1 {
|
||||
return "Team"
|
||||
}
|
||||
return "Department"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -20,15 +21,18 @@ type RelyingPartyService interface {
|
||||
type relyingPartyService struct {
|
||||
hydraService *HydraAdminService
|
||||
ketoService KetoService
|
||||
outboxRepo repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func NewRelyingPartyService(
|
||||
hydraService *HydraAdminService,
|
||||
ketoService KetoService,
|
||||
outboxRepo repository.KetoOutboxRepository,
|
||||
) RelyingPartyService {
|
||||
return &relyingPartyService{
|
||||
hydraService: hydraService,
|
||||
ketoService: ketoService,
|
||||
outboxRepo: outboxRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,23 +42,22 @@ func (s *relyingPartyService) Create(ctx context.Context, tenantID string, clien
|
||||
client.Metadata = make(map[string]interface{})
|
||||
}
|
||||
client.Metadata["tenant_id"] = tenantID
|
||||
// Ensure description is in metadata if provided in some other way?
|
||||
// The input 'client' is domain.HydraClient. It doesn't have a separate description field.
|
||||
// Assuming caller puts description in metadata.
|
||||
|
||||
createdClient, err := s.hydraService.CreateClient(ctx, client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
||||
}
|
||||
|
||||
// 2. Create Relation in Keto
|
||||
// RelyingParty:<client_id>#parent_tenant@Tenant:<tenant_id>
|
||||
err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create keto relation for relying party", "error", err, "client_id", createdClient.ClientID)
|
||||
// Try to cleanup Hydra client
|
||||
_ = s.hydraService.DeleteClient(ctx, createdClient.ClientID)
|
||||
return nil, err
|
||||
// 2. Create Relation in Keto via Outbox
|
||||
// RelyingParty:<client_id>#parents@Tenant:<tenant_id>
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: createdClient.ClientID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + tenantID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
return s.mapHydraToDomain(createdClient), nil
|
||||
@@ -71,28 +74,22 @@ func (s *relyingPartyService) Get(ctx context.Context, clientID string) (*domain
|
||||
|
||||
func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
|
||||
// 1. Fetch ClientIDs from Keto
|
||||
// Subject: Tenant:<tenantID>, Relation: parent_tenant, Namespace: RelyingParty
|
||||
// Note: ListRelations checks "who has relation to subject".
|
||||
// Relation tuple: RelyingParty:cid # parent_tenant @ Tenant:tid
|
||||
// We want to find objects where subject=Tenant:tid.
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parent_tenant", "Tenant:"+tenantID)
|
||||
// Relation tuple: RelyingParty:cid # parents @ Tenant:tid
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parents", "Tenant:"+tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rps []domain.RelyingParty
|
||||
for _, t := range tuples {
|
||||
// Object is "RelyingParty:clientId"
|
||||
if len(t.Object) > 13 && t.Object[:13] == "RelyingParty:" {
|
||||
clientID := t.Object[13:]
|
||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err)
|
||||
continue
|
||||
}
|
||||
if rp := s.mapHydraToDomain(client); rp != nil {
|
||||
rps = append(rps, *rp)
|
||||
}
|
||||
clientID := t.Object
|
||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err)
|
||||
continue
|
||||
}
|
||||
if rp := s.mapHydraToDomain(client); rp != nil {
|
||||
rps = append(rps, *rp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,16 +97,6 @@ func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]doma
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
|
||||
// This might be heavy if there are many clients.
|
||||
// Hydra doesn't support "List all clients" easily without pagination.
|
||||
// Assuming HydraAdminService has ListClients or similar?
|
||||
// The interface wasn't shown, but assuming it's available or we skip implementation.
|
||||
// For now, let's return empty or error?
|
||||
// Wait, repo.ListAll was used.
|
||||
// Let's assume we can't implement efficient ListAll without DB,
|
||||
// UNLESS we use Keto to list all RelyingParties (if Keto supports listing all objects in namespace).
|
||||
// Keto doesn't support listing all objects easily.
|
||||
// But `hydraService` likely has `ListClients`.
|
||||
return nil, fmt.Errorf("ListAll not implemented in SSOT mode yet")
|
||||
}
|
||||
|
||||
@@ -136,7 +123,7 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
|
||||
// 1. Get client to find tenantID (for Keto cleanup)
|
||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return err // Or ignore if not found?
|
||||
return err
|
||||
}
|
||||
tenantID := ""
|
||||
if client.Metadata != nil {
|
||||
@@ -150,9 +137,15 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Delete from Keto
|
||||
if tenantID != "" {
|
||||
_ = s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID)
|
||||
// 3. Delete from Keto via Outbox
|
||||
if s.outboxRepo != nil && tenantID != "" {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: clientID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + tenantID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -16,52 +16,15 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
type MockKetoService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||
args := m.Called(ctx, subject, namespace, object, relation)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]RelationTuple), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||
args := m.Called(ctx, namespace, relation, subject)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
|
||||
// --- Test Helpers ---
|
||||
|
||||
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -83,7 +46,8 @@ func mockHydraClient(handler http.Handler) *http.Client {
|
||||
// --- Tests ---
|
||||
|
||||
func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
|
||||
tenantID := "tenant-1"
|
||||
inputClient := domain.HydraClient{
|
||||
@@ -113,25 +77,23 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||
HTTPClient: mockHydraClient(hydraHandler),
|
||||
}
|
||||
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "generated-client-id", "parent_tenant", "Tenant:"+tenantID).Return(nil)
|
||||
// Keto sync via Outbox using 'parents' relation
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "RelyingParty" && e.Object == "generated-client-id" && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID
|
||||
})).Return(nil)
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
rp, err := svc.Create(context.Background(), tenantID, inputClient)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if rp.ClientID != "generated-client-id" {
|
||||
t.Errorf("expected client id generated-client-id, got %s", rp.ClientID)
|
||||
}
|
||||
if rp.TenantID != tenantID {
|
||||
t.Errorf("expected tenant id %s, got %s", tenantID, rp.TenantID)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "generated-client-id", rp.ClientID)
|
||||
assert.Equal(t, tenantID, rp.TenantID)
|
||||
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRelyingPartyService_Create_HydraFail(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -141,54 +103,15 @@ func TestRelyingPartyService_Create_HydraFail(t *testing.T) {
|
||||
HTTPClient: mockHydraClient(hydraHandler),
|
||||
}
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
_, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error from hydra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelyingPartyService_Create_KetoFail_Rollback(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
|
||||
clientID := "rollback-client-id"
|
||||
deleteCalled := false
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, clientID) {
|
||||
deleteCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
hydraSvc := &HydraAdminService{
|
||||
AdminURL: "http://hydra:4445",
|
||||
HTTPClient: mockHydraClient(hydraHandler),
|
||||
}
|
||||
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", clientID, "parent_tenant", "Tenant:tenant-1").Return(errors.New("keto error"))
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
_, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error from keto")
|
||||
}
|
||||
if !deleteCalled {
|
||||
t.Error("expected hydra client cleanup on keto failure")
|
||||
}
|
||||
|
||||
mockKeto.AssertExpectations(t)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRelyingPartyService_Get_Success(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
clientID := "client-123"
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -205,21 +128,16 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
|
||||
HTTPClient: mockHydraClient(hydraHandler),
|
||||
}
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
rp, hc, err := svc.Get(context.Background(), clientID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if rp.Name != "Hydra Name" {
|
||||
t.Errorf("expected Hydra Name, got %s", rp.Name)
|
||||
}
|
||||
if hc.ClientName != "Hydra Name" {
|
||||
t.Errorf("expected Hydra Name, got %s", hc.ClientName)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hydra Name", rp.Name)
|
||||
assert.Equal(t, "Hydra Name", hc.ClientName)
|
||||
}
|
||||
|
||||
func TestRelyingPartyService_Update_Success(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
clientID := "client-123"
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -235,20 +153,17 @@ func TestRelyingPartyService_Update_Success(t *testing.T) {
|
||||
HTTPClient: mockHydraClient(hydraHandler),
|
||||
}
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
|
||||
updateReq := domain.HydraClient{ClientName: "New Name"}
|
||||
rp, err := svc.Update(context.Background(), clientID, updateReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
if rp.Name != "New Name" {
|
||||
t.Errorf("expected New Name, got %s", rp.Name)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "New Name", rp.Name)
|
||||
}
|
||||
|
||||
func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
clientID := "client-123"
|
||||
tenantID := "tenant-1"
|
||||
|
||||
@@ -273,13 +188,14 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||
HTTPClient: mockHydraClient(hydraHandler),
|
||||
}
|
||||
|
||||
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID).Return(nil)
|
||||
// Delete relation via Outbox using 'parents'
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "RelyingParty" && e.Object == clientID && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID
|
||||
})).Return(nil)
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
err := svc.Delete(context.Background(), clientID)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,18 @@ type TenantService interface {
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
repo repository.TenantRepository
|
||||
userRepo repository.UserRepository
|
||||
keto KetoService
|
||||
repo repository.TenantRepository
|
||||
userRepo repository.UserRepository
|
||||
keto KetoService
|
||||
outboxRepo repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository) TenantService {
|
||||
return &tenantService{repo: repo, userRepo: userRepo}
|
||||
func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository, outboxRepo repository.KetoOutboxRepository) TenantService {
|
||||
return &tenantService{
|
||||
repo: repo,
|
||||
userRepo: userRepo,
|
||||
outboxRepo: outboxRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tenantService) SetKetoService(keto KetoService) {
|
||||
@@ -46,56 +51,32 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
}
|
||||
|
||||
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
|
||||
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
directAdminIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list direct tenants", "userID", userID, "error", err)
|
||||
slog.Error("Failed to list direct admin tenants", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
|
||||
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
|
||||
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
|
||||
// 2. 직접 소유자(조직장)인 테넌트 ID 목록 (Tenant:ID#owners@User:ID)
|
||||
directOwnerIDs, err := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
|
||||
memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list group memberships", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
|
||||
var inheritedTenantIDs []string
|
||||
allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
|
||||
for _, groupID := range allMyGroups {
|
||||
// 해당 그룹에 부여된 테넌트 관리 권한 역추적
|
||||
relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
|
||||
if err == nil {
|
||||
for _, r := range relations {
|
||||
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
|
||||
}
|
||||
}
|
||||
// view 권한도 관리 가능 목록에 포함 (필요 시)
|
||||
relationsView, err := s.keto.ListRelations(ctx, "Tenant", "", "view", "UserGroup:"+groupID+"#members")
|
||||
if err == nil {
|
||||
for _, r := range relationsView {
|
||||
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
|
||||
}
|
||||
}
|
||||
slog.Error("Failed to list owned tenants", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 합산 및 중복 제거
|
||||
allIDsMap := make(map[string]bool)
|
||||
for _, id := range directTenantIDs {
|
||||
for _, id := range directAdminIDs {
|
||||
allIDsMap[id] = true
|
||||
}
|
||||
for _, id := range ownedGroupIDs {
|
||||
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
|
||||
}
|
||||
for _, id := range inheritedTenantIDs {
|
||||
for _, id := range directOwnerIDs {
|
||||
allIDsMap[id] = true
|
||||
}
|
||||
|
||||
// Note: 상속된 권한(부모의 어드민이 자식의 어드민)은 Keto의 OPL에서 처리되므로,
|
||||
// 특정 유저가 'view' 또는 'manage' 권한을 가진 테넌트를 모두 찾으려면
|
||||
// Keto의 'expand' 또는 'list objects' 기능을 더 고도화하거나,
|
||||
// 여기서는 직접 할당된 부모 테넌트를 기준으로 하위 테넌트 정보를 추가 조회하는 로직이 필요할 수 있습니다.
|
||||
// 우선 직접 할당된 테넌트들만 반환합니다.
|
||||
|
||||
allIDs := make([]string, 0, len(allIDsMap))
|
||||
for id := range allIDsMap {
|
||||
allIDs = append(allIDs, id)
|
||||
@@ -125,6 +106,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
||||
|
||||
// 2. Create Tenant
|
||||
tenant := &domain.Tenant{
|
||||
Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
@@ -135,6 +117,17 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||
for _, d := range domains {
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil {
|
||||
@@ -158,6 +151,7 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
|
||||
}
|
||||
|
||||
tenant := &domain.Tenant{
|
||||
Type: domain.TenantTypeCompany,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
@@ -188,21 +182,22 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// [Keto] Sync relation
|
||||
if s.keto != nil {
|
||||
// [Keto] Sync relation via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||
slog.Info("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||
slog.Info("Queueing tenant admin 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
|
||||
err = s.keto.CreateRelation(ctx, "Tenant", tenant.ID, "admin", "User:"+user.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to assign tenant admin in Keto", "tenant", tenant.ID, "user", user.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("Assigned tenant admin in Keto", "tenant", tenant.ID, "user", user.ID)
|
||||
}
|
||||
// User exists, assign Admin role in Keto via Outbox
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "admins",
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -116,11 +116,10 @@ func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, sea
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
svc := NewTenantService(mockRepo, nil)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
svc := NewTenantService(mockRepo, nil, mockOutbox)
|
||||
|
||||
ctx := context.Background()
|
||||
name := "New Tenant"
|
||||
@@ -142,7 +141,8 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
|
||||
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
svc := NewTenantService(mockRepo, nil)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
svc := NewTenantService(mockRepo, nil, mockOutbox)
|
||||
|
||||
ctx := context.Background()
|
||||
name := "Public Tenant"
|
||||
@@ -165,8 +165,9 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
mockUserRepo := new(MockUserRepoForTenant)
|
||||
mockKeto := new(MockKetoSvcForTenant)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
|
||||
svc := NewTenantService(mockRepo, mockUserRepo)
|
||||
svc := NewTenantService(mockRepo, mockUserRepo, mockOutbox)
|
||||
svc.SetKetoService(mockKeto)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -183,11 +184,14 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
||||
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
|
||||
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
||||
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
|
||||
mockKeto.On("CreateRelation", ctx, "Tenant", tenantID, "admin", "User:"+userID).Return(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 == "admins" && e.Subject == "User:"+userID
|
||||
})).Return(nil)
|
||||
|
||||
err := svc.ApproveTenant(ctx, tenantID)
|
||||
assert.NoError(t, err)
|
||||
mockRepo.AssertExpectations(t)
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type userGroupService struct {
|
||||
userRepo repository.UserRepository
|
||||
tenantRepo repository.TenantRepository
|
||||
ketoService KetoService
|
||||
outboxRepo repository.KetoOutboxRepository
|
||||
kratos *KratosAdminService
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ func NewUserGroupService(
|
||||
userRepo repository.UserRepository,
|
||||
tenantRepo repository.TenantRepository,
|
||||
keto KetoService,
|
||||
outbox repository.KetoOutboxRepository,
|
||||
kratos *KratosAdminService,
|
||||
) UserGroupService {
|
||||
return &userGroupService{
|
||||
@@ -44,19 +46,55 @@ func NewUserGroupService(
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
ketoService: keto,
|
||||
outboxRepo: outbox,
|
||||
kratos: kratos,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *userGroupService) Create(ctx context.Context, group *domain.UserGroup) error {
|
||||
// [Polymorphic Tenant] Create corresponding Tenant record first
|
||||
parentID := group.ParentID
|
||||
if parentID == nil || *parentID == "" {
|
||||
// If no parent user group, the parent is the company tenant
|
||||
parentID = &group.TenantID
|
||||
}
|
||||
|
||||
tenant := &domain.Tenant{
|
||||
ID: group.ID, // Use same ID for 1:1 join
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: parentID,
|
||||
Name: group.Name,
|
||||
Slug: "ug-" + group.ID, // Temporary slug for user groups
|
||||
Description: group.Description,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
|
||||
if group.ID == "" {
|
||||
// Let BeforeCreate generate ID if not provided, then sync
|
||||
// But usually we want to control the ID for 1:1 join
|
||||
}
|
||||
|
||||
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
|
||||
slog.Error("Failed to create tenant record for user group", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update group.ID to match tenant.ID if it was generated
|
||||
group.ID = tenant.ID
|
||||
|
||||
if err := s.repo.Create(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Keto: UserGroup:<id>#parent_tenant@Tenant:<tid>
|
||||
err := s.ketoService.CreateRelation(ctx, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create keto relation for user group", "error", err, "group_id", group.ID)
|
||||
// Keto Hierarchy via Outbox: Tenant:<child_id>#parents@Tenant:<parent_id>
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: group.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *parentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -77,8 +115,8 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch members from Keto
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
|
||||
// Fetch members from Keto (Tenant namespace)
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", group.ID, "members", "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
|
||||
return nil, err
|
||||
@@ -142,7 +180,7 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.
|
||||
|
||||
// For each group, fetch member count from Keto
|
||||
for i := range groups {
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", groups[i].ID, "members", "")
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", groups[i].ID, "members", "")
|
||||
if err == nil {
|
||||
// Create dummy members just to carry the count for the JSON response
|
||||
groups[i].Members = make([]domain.User, len(tuples))
|
||||
@@ -153,30 +191,38 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.
|
||||
}
|
||||
|
||||
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||
// Keto: UserGroup:<groupID>#members@User:<userID>
|
||||
err := s.ketoService.CreateRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to sync group membership to keto", "error", err, "group", groupID, "user", userID)
|
||||
return err
|
||||
// Keto via Outbox: Tenant:<groupID>#members@User:<userID>
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: groupID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
|
||||
// Keto: Delete relation
|
||||
err := s.ketoService.DeleteRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to remove group membership from keto", "error", err, "group", groupID, "user", userID)
|
||||
return err
|
||||
// Keto via Outbox: Delete relation
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: groupID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
|
||||
// Query: namespace=Tenant, subject=UserGroup:groupID#members
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
// Query: namespace=Tenant, subject=Tenant:groupID#members
|
||||
subject := "Tenant:" + groupID + "#members"
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
|
||||
@@ -213,23 +259,31 @@ func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]dom
|
||||
}
|
||||
|
||||
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
|
||||
// This means all members of the group have the relation on the tenant.
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
|
||||
if err != nil {
|
||||
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
|
||||
return err
|
||||
// Keto via Outbox: Tenant:<tenantID>#<relation>@Tenant:<groupID>#members
|
||||
if s.outboxRepo != nil {
|
||||
subject := "Tenant:" + groupID + "#members"
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
|
||||
if err != nil {
|
||||
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
|
||||
return err
|
||||
// Keto via Outbox: Delete relation
|
||||
if s.outboxRepo != nil {
|
||||
subject := "Tenant:" + groupID + "#members"
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ type MockTenantRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { return nil }
|
||||
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
|
||||
return m.Called(ctx, tenant).Error(0)
|
||||
}
|
||||
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { return nil }
|
||||
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
@@ -98,66 +100,81 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestUserGroupService_Create(t *testing.T) {
|
||||
mockRepo := new(MockUserGroupRepository)
|
||||
mockKeto := new(MockKetoService)
|
||||
// We don't need userRepo or tenantRepo for Create
|
||||
svc := NewUserGroupService(mockRepo, nil, nil, mockKeto, nil)
|
||||
mockTenantRepo := new(MockTenantRepository)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil)
|
||||
|
||||
group := &domain.UserGroup{
|
||||
ID: "group-1",
|
||||
TenantID: "tenant-1",
|
||||
TenantID: "company-1",
|
||||
Name: "Test Group",
|
||||
}
|
||||
|
||||
// Mock Tenant creation (Polymorphic)
|
||||
mockTenantRepo.On("Create", mock.Anything, mock.MatchedBy(func(ten *domain.Tenant) bool {
|
||||
return ten.Type == domain.TenantTypeUserGroup && ten.ID == group.ID
|
||||
})).Return(nil)
|
||||
|
||||
// Mock UserGroup creation
|
||||
mockRepo.On("Create", mock.Anything, group).Return(nil)
|
||||
mockKeto.On("CreateRelation", mock.Anything, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID).Return(nil)
|
||||
|
||||
// Mock Keto sync via Outbox
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == group.ID && e.Relation == "parents" && e.Subject == "Tenant:"+group.TenantID
|
||||
})).Return(nil)
|
||||
|
||||
err := svc.Create(context.Background(), group)
|
||||
assert.NoError(t, err)
|
||||
mockTenantRepo.AssertExpectations(t)
|
||||
mockRepo.AssertExpectations(t)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AddMember(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
svc := NewUserGroupService(nil, nil, nil, nil, mockOutbox, nil)
|
||||
|
||||
groupID := "group-1"
|
||||
userID := "user-1"
|
||||
|
||||
mockKeto.On("CreateRelation", mock.Anything, "UserGroup", groupID, "members", "User:"+userID).Return(nil)
|
||||
// Using Outbox and Tenant namespace
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil)
|
||||
|
||||
err := svc.AddMember(context.Background(), groupID, userID)
|
||||
assert.NoError(t, err)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
svc := NewUserGroupService(nil, nil, nil, nil, mockOutbox, nil)
|
||||
|
||||
groupID := "group-1"
|
||||
tenantID := "tenant-alpha"
|
||||
relation := "manage"
|
||||
|
||||
expectedSubject := "UserGroup:" + groupID + "#members"
|
||||
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, relation, expectedSubject).Return(nil)
|
||||
expectedSubject := "Tenant:" + groupID + "#members"
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == relation && e.Subject == expectedSubject
|
||||
})).Return(nil)
|
||||
|
||||
err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation)
|
||||
assert.NoError(t, err)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserGroupService_ListRoles(t *testing.T) {
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockTenantRepo := new(MockTenantRepository)
|
||||
svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil)
|
||||
svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil, nil)
|
||||
|
||||
groupID := "group-1"
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
subject := "Tenant:" + groupID + "#members"
|
||||
|
||||
// Mock Keto relations
|
||||
tuples := []RelationTuple{
|
||||
@@ -186,15 +203,11 @@ func TestUserGroupService_ListRoles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
|
||||
// This tests the logic where a user is in Keto but not in local DB
|
||||
mockRepo := new(MockUserGroupRepository)
|
||||
mockKeto := new(MockKetoService)
|
||||
mockKeto := new(MockKetoServiceShared)
|
||||
mockUserRepo := new(MockUserRepository)
|
||||
// We need a way to mock KratosAdminService but it's a struct, not an interface.
|
||||
// For this POC test, we'll focus on the Keto and UserRepo parts.
|
||||
// If needed, we can refactor KratosAdminService to an interface.
|
||||
|
||||
svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil)
|
||||
svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil, nil)
|
||||
|
||||
groupID := "group-1"
|
||||
mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil)
|
||||
@@ -202,14 +215,13 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
|
||||
tuples := []RelationTuple{
|
||||
{Object: groupID, Relation: "members", SubjectID: "User:u1"},
|
||||
}
|
||||
mockKeto.On("ListRelations", mock.Anything, "UserGroup", groupID, "members", "").Return(tuples, nil)
|
||||
// Note: Transitioned to 'Tenant' namespace for groups
|
||||
mockKeto.On("ListRelations", mock.Anything, "Tenant", groupID, "members", "").Return(tuples, nil)
|
||||
|
||||
// User u1 not in local DB
|
||||
mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil)
|
||||
|
||||
group, err := svc.Get(context.Background(), groupID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, group)
|
||||
// Members should be empty since Kratos is nil in this test setup
|
||||
assert.Len(t, group.Members, 0)
|
||||
}
|
||||
|
||||
@@ -2,43 +2,23 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
||||
|
||||
class User implements Namespace {}
|
||||
|
||||
class TenantGroup implements Namespace {
|
||||
related: {
|
||||
admins: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class UserGroup implements Namespace {
|
||||
related: {
|
||||
members: User[]
|
||||
parent_tenant: Tenant[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
check_member: (ctx: Context): boolean =>
|
||||
this.related.members.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
|
||||
class Tenant implements Namespace {
|
||||
related: {
|
||||
admins: (User | SubjectSet<UserGroup, "members">)[]
|
||||
members: (User | SubjectSet<UserGroup, "members">)[]
|
||||
parent: Tenant[]
|
||||
parent_group: TenantGroup[]
|
||||
owners: User[]
|
||||
admins: (User | SubjectSet<Tenant, "owners">)[]
|
||||
members: User[]
|
||||
parents: Tenant[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.members.includes(ctx.subject) ||
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parent.traverse((p) => p.permits.view(ctx)) ||
|
||||
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
||||
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||
|
||||
manage: (ctx: Context): boolean =>
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parent.traverse((p) => p.permits.manage(ctx)) ||
|
||||
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
||||
this.related.parents.traverse((p) => p.permits.manage(ctx)),
|
||||
|
||||
create_subtenant: (ctx: Context): boolean =>
|
||||
this.permits.manage(ctx)
|
||||
@@ -47,24 +27,30 @@ class Tenant implements Namespace {
|
||||
|
||||
class RelyingParty implements Namespace {
|
||||
related: {
|
||||
owners: (User | SubjectSet<UserGroup, "members">)[]
|
||||
parent_tenant: Tenant[]
|
||||
admins: User[]
|
||||
parents: Tenant[]
|
||||
access: (User | SubjectSet<Tenant, "members"> | SubjectSet<System, "authenticated_users">)[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.owners.includes(ctx.subject) ||
|
||||
this.related.parent_tenant.traverse((t) => t.permits.view(ctx)),
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.view(ctx)),
|
||||
|
||||
manage: (ctx: Context): boolean =>
|
||||
this.related.owners.includes(ctx.subject) ||
|
||||
this.related.parent_tenant.traverse((t) => t.permits.manage(ctx))
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.manage(ctx)),
|
||||
|
||||
access: (ctx: Context): boolean =>
|
||||
this.related.access.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
class System implements Namespace {
|
||||
related: {
|
||||
super_admins: User[]
|
||||
authenticated_users: User[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
|
||||
Reference in New Issue
Block a user