1
0
forked from baron/baron-sso

Ory Keto ReBAC Policy & Relation Tuple Architecture

This commit is contained in:
2026-02-20 17:56:05 +09:00
parent 226a236bf2
commit 2ec2653bfb
23 changed files with 980 additions and 396 deletions

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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)
}
}

View File

@@ -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 {

View 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
}

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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",

View 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"})
}

View File

@@ -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)

View File

@@ -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)

View 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
}

View 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)
}
}

View 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)
}

View 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: &currentParentID,
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: &currentParentID,
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"
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 = {