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/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/validator"
|
"baron-sso-backend/internal/validator"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -209,6 +210,12 @@ func main() {
|
|||||||
slog.Error("❌ Bootstrap failed", "error", err)
|
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
|
// [Moved & Enhanced] Seed Admin Identity & Sync Local Role
|
||||||
if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||||
@@ -253,28 +260,32 @@ func main() {
|
|||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
|
|
||||||
tenantService := service.NewTenantService(tenantRepo, userRepo)
|
tenantService := service.NewTenantService(tenantRepo, userRepo, ketoOutboxRepo)
|
||||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
|
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
|
|
||||||
hydraService := service.NewHydraAdminService()
|
hydraService := service.NewHydraAdminService()
|
||||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||||
secretRepo := repository.NewClientSecretRepository(db)
|
secretRepo := repository.NewClientSecretRepository(db)
|
||||||
consentRepo := repository.NewClientConsentRepository(db)
|
consentRepo := repository.NewClientConsentRepository(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
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)
|
adminHandler := handler.NewAdminHandler(ketoService)
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
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)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
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)
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
|
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
||||||
|
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
appEnv := getEnv("APP_ENV", "dev")
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
app := fiber.New(fiber.Config{
|
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.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||||
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
admin.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)
|
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||||
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
|
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
|
||||||
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
org.Post("/import", orgChartHandler.ImportCSV) // CSV Import API
|
||||||
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||||
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
|
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||||
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
org.Get("/:id", userGroupHandler.Get)
|
||||||
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
org.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||||
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
org.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||||
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
org.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||||
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
org.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||||
userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
|
org.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||||
userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
|
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)
|
// Relying Party Management (Global List)
|
||||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
&domain.IdentityProviderConfig{},
|
&domain.IdentityProviderConfig{},
|
||||||
&domain.ClientSecret{},
|
&domain.ClientSecret{},
|
||||||
&domain.ClientConsent{},
|
&domain.ClientConsent{},
|
||||||
|
&domain.KetoOutbox{},
|
||||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
// &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))
|
slog.Info("Syncing tenants to Keto", "count", len(tenants))
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
if t.ParentID != nil {
|
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 {
|
for _, u := range users {
|
||||||
// Membership
|
// Membership
|
||||||
if u.TenantID != nil {
|
if u.TenantID != nil {
|
||||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", u.ID)
|
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", "User:"+u.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
if u.Role == domain.RoleSuperAdmin {
|
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 {
|
} 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...")
|
slog.Info("[Bootstrap] Seeding initial tenants...")
|
||||||
repo := repository.NewTenantRepository(db)
|
repo := repository.NewTenantRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
svc := service.NewTenantService(repo, userRepo)
|
outboxRepo := repository.NewKetoOutboxRepository(db)
|
||||||
|
svc := service.NewTenantService(repo, userRepo, outboxRepo)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
for _, config := range defaultTenants {
|
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"
|
TenantStatusDeleted = "deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Tenant types
|
||||||
|
const (
|
||||||
|
TenantTypePersonal = "PERSONAL"
|
||||||
|
TenantTypeCompany = "COMPANY"
|
||||||
|
TenantTypeCompanyGroup = "COMPANY_GROUP"
|
||||||
|
TenantTypeUserGroup = "USER_GROUP"
|
||||||
|
)
|
||||||
|
|
||||||
// Tenant represents a tenant model stored in PostgreSQL.
|
// Tenant represents a tenant model stored in PostgreSQL.
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
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
|
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type User struct {
|
|||||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||||
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
|
Position string `json:"position"` // 직급 (예: 수석, 책임, 선임)
|
||||||
|
JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||||
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||||
Status string `gorm:"default:'active'" json:"status"`
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ import (
|
|||||||
type UserGroup struct {
|
type UserGroup struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"`
|
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"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
UnitType string `json:"unitType"` // 부, 국, 팀, 셀 등
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
Members []User `gorm:"-" json:"members,omitempty"`
|
Parent *UserGroup `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||||
|
Members []User `gorm:"-" json:"members,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupRole struct {
|
type GroupRole struct {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type AuthHandler struct {
|
|||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
|
KetoOutboxRepo repository.KetoOutboxRepository
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
}
|
}
|
||||||
@@ -147,7 +148,7 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
|
|||||||
return false, int(interval.Seconds())
|
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{
|
return &AuthHandler{
|
||||||
SmsService: service.NewSmsService(),
|
SmsService: service.NewSmsService(),
|
||||||
EmailService: service.NewEmailService(),
|
EmailService: service.NewEmailService(),
|
||||||
@@ -159,6 +160,7 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
|
|||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
TenantService: tenantService,
|
TenantService: tenantService,
|
||||||
KetoService: ketoService,
|
KetoService: ketoService,
|
||||||
|
KetoOutboxRepo: ketoOutboxRepo,
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
ConsentRepo: consentRepo,
|
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)
|
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
|
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)
|
}(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{
|
return c.JSON(fiber.Map{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "User registered successfully",
|
"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
|
DB *gorm.DB
|
||||||
Service service.TenantService
|
Service service.TenantService
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
KratosAdmin *service.KratosAdminService
|
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{
|
return &TenantHandler{
|
||||||
DB: db,
|
DB: db,
|
||||||
Service: svc,
|
Service: svc,
|
||||||
Keto: keto,
|
Keto: keto,
|
||||||
|
KetoOutbox: outbox,
|
||||||
KratosAdmin: kratos,
|
KratosAdmin: kratos,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +326,7 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch admins from Keto
|
// 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 {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
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"})
|
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 {
|
if h.KetoOutbox != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
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"})
|
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 {
|
if h.KetoOutbox != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
|||||||
@@ -14,20 +14,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
OryProvider *service.OryProvider
|
OryProvider *service.OryProvider
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
UserRepo repository.UserRepository
|
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{
|
return &UserHandler{
|
||||||
KratosAdmin: kratosAdmin,
|
KratosAdmin: kratosAdmin,
|
||||||
OryProvider: oryProvider,
|
OryProvider: oryProvider,
|
||||||
TenantService: tenantService,
|
TenantService: tenantService,
|
||||||
KetoService: ketoService,
|
KetoService: ketoService,
|
||||||
UserRepo: userRepo,
|
KetoOutboxRepo: ketoOutboxRepo,
|
||||||
|
UserRepo: userRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,21 +317,36 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}(localUser)
|
}(localUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Keto] Sync relations
|
// [Keto] Sync relations via Outbox
|
||||||
if h.KetoService != nil {
|
if h.KetoOutboxRepo != nil {
|
||||||
go func() {
|
// 1. Tenant Membership
|
||||||
ctx := context.Background()
|
if localUser.TenantID != nil {
|
||||||
// 1. Tenant Membership
|
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||||
if localUser.TenantID != nil {
|
Namespace: "Tenant",
|
||||||
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID)
|
Object: *localUser.TenantID,
|
||||||
}
|
Relation: "members",
|
||||||
// 2. Role Specifics
|
Subject: "User:" + identityID,
|
||||||
if role == domain.RoleSuperAdmin {
|
Action: domain.KetoOutboxActionCreate,
|
||||||
_ = 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)
|
// 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)
|
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 동기화로 처리합니다.
|
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||||
|
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
|
||||||
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
|
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := h.UserRepo.Update(ctx, u); err == nil {
|
if err := h.UserRepo.Update(ctx, u); err == nil {
|
||||||
// [Keto Sync on Role Change]
|
// [Keto Sync on Role Change] via Outbox
|
||||||
if h.KetoService != nil && rRole != nil && *rRole != oRole {
|
if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
|
||||||
uID := u.ID
|
uID := u.ID
|
||||||
newR := *rRole
|
newR := *rRole
|
||||||
if oRole == domain.RoleSuperAdmin {
|
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 != "" {
|
} 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 {
|
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 {
|
} 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 {
|
} else {
|
||||||
@@ -552,16 +594,17 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Keto] Cleanup relations (Best effort)
|
// [Keto] Cleanup relations via Outbox
|
||||||
if h.KetoService != nil {
|
if h.KetoOutboxRepo != nil {
|
||||||
go func(uID string) {
|
ctx := context.Background()
|
||||||
ctx := context.Background()
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
// Fetch user from DB before cleanup if needed, but here we cleanup common namespaces
|
Namespace: "System",
|
||||||
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
Object: "global",
|
||||||
|
Relation: "super_admins",
|
||||||
// If we had more complex relations, we would query Keto first or use user metadata
|
Subject: "User:" + userID,
|
||||||
slog.Info("Keto relations cleaned up for user", "userID", uID)
|
Action: domain.KetoOutboxActionDelete,
|
||||||
}(userID)
|
})
|
||||||
|
// Additional cleanup for tenants could be added here if we keep track of user's current tenants
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
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 (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -20,15 +21,18 @@ type RelyingPartyService interface {
|
|||||||
type relyingPartyService struct {
|
type relyingPartyService struct {
|
||||||
hydraService *HydraAdminService
|
hydraService *HydraAdminService
|
||||||
ketoService KetoService
|
ketoService KetoService
|
||||||
|
outboxRepo repository.KetoOutboxRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRelyingPartyService(
|
func NewRelyingPartyService(
|
||||||
hydraService *HydraAdminService,
|
hydraService *HydraAdminService,
|
||||||
ketoService KetoService,
|
ketoService KetoService,
|
||||||
|
outboxRepo repository.KetoOutboxRepository,
|
||||||
) RelyingPartyService {
|
) RelyingPartyService {
|
||||||
return &relyingPartyService{
|
return &relyingPartyService{
|
||||||
hydraService: hydraService,
|
hydraService: hydraService,
|
||||||
ketoService: ketoService,
|
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 = make(map[string]interface{})
|
||||||
}
|
}
|
||||||
client.Metadata["tenant_id"] = tenantID
|
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)
|
createdClient, err := s.hydraService.CreateClient(ctx, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create Relation in Keto
|
// 2. Create Relation in Keto via Outbox
|
||||||
// RelyingParty:<client_id>#parent_tenant@Tenant:<tenant_id>
|
// RelyingParty:<client_id>#parents@Tenant:<tenant_id>
|
||||||
err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID)
|
if s.outboxRepo != nil {
|
||||||
if err != nil {
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
slog.Error("Failed to create keto relation for relying party", "error", err, "client_id", createdClient.ClientID)
|
Namespace: "RelyingParty",
|
||||||
// Try to cleanup Hydra client
|
Object: createdClient.ClientID,
|
||||||
_ = s.hydraService.DeleteClient(ctx, createdClient.ClientID)
|
Relation: "parents",
|
||||||
return nil, err
|
Subject: "Tenant:" + tenantID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.mapHydraToDomain(createdClient), nil
|
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) {
|
func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
|
||||||
// 1. Fetch ClientIDs from Keto
|
// 1. Fetch ClientIDs from Keto
|
||||||
// Subject: Tenant:<tenantID>, Relation: parent_tenant, Namespace: RelyingParty
|
// Relation tuple: RelyingParty:cid # parents @ Tenant:tid
|
||||||
// Note: ListRelations checks "who has relation to subject".
|
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parents", "Tenant:"+tenantID)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var rps []domain.RelyingParty
|
var rps []domain.RelyingParty
|
||||||
for _, t := range tuples {
|
for _, t := range tuples {
|
||||||
// Object is "RelyingParty:clientId"
|
clientID := t.Object
|
||||||
if len(t.Object) > 13 && t.Object[:13] == "RelyingParty:" {
|
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||||
clientID := t.Object[13:]
|
if err != nil {
|
||||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err)
|
||||||
if err != nil {
|
continue
|
||||||
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)
|
||||||
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) {
|
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")
|
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)
|
// 1. Get client to find tenantID (for Keto cleanup)
|
||||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err // Or ignore if not found?
|
return err
|
||||||
}
|
}
|
||||||
tenantID := ""
|
tenantID := ""
|
||||||
if client.Metadata != nil {
|
if client.Metadata != nil {
|
||||||
@@ -150,9 +137,15 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Delete from Keto
|
// 3. Delete from Keto via Outbox
|
||||||
if tenantID != "" {
|
if s.outboxRepo != nil && tenantID != "" {
|
||||||
_ = s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID)
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: clientID,
|
||||||
|
Relation: "parents",
|
||||||
|
Subject: "Tenant:" + tenantID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -16,52 +16,15 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"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 ---
|
// --- Test Helpers ---
|
||||||
|
|
||||||
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
|
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
@@ -83,7 +46,8 @@ func mockHydraClient(handler http.Handler) *http.Client {
|
|||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
func TestRelyingPartyService_Create_Success(t *testing.T) {
|
func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockKeto := new(MockKetoServiceShared)
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
|
||||||
tenantID := "tenant-1"
|
tenantID := "tenant-1"
|
||||||
inputClient := domain.HydraClient{
|
inputClient := domain.HydraClient{
|
||||||
@@ -113,25 +77,23 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
|||||||
HTTPClient: mockHydraClient(hydraHandler),
|
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)
|
rp, err := svc.Create(context.Background(), tenantID, inputClient)
|
||||||
if err != nil {
|
assert.NoError(t, err)
|
||||||
t.Fatalf("Create failed: %v", err)
|
assert.Equal(t, "generated-client-id", rp.ClientID)
|
||||||
}
|
assert.Equal(t, tenantID, rp.TenantID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
mockKeto.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRelyingPartyService_Create_HydraFail(t *testing.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) {
|
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
@@ -141,54 +103,15 @@ func TestRelyingPartyService_Create_HydraFail(t *testing.T) {
|
|||||||
HTTPClient: mockHydraClient(hydraHandler),
|
HTTPClient: mockHydraClient(hydraHandler),
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||||
_, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{})
|
_, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{})
|
||||||
|
|
||||||
if err == nil {
|
assert.Error(t, err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRelyingPartyService_Get_Success(t *testing.T) {
|
func TestRelyingPartyService_Get_Success(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockKeto := new(MockKetoServiceShared)
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
clientID := "client-123"
|
clientID := "client-123"
|
||||||
|
|
||||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -205,21 +128,16 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
|
|||||||
HTTPClient: mockHydraClient(hydraHandler),
|
HTTPClient: mockHydraClient(hydraHandler),
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||||
rp, hc, err := svc.Get(context.Background(), clientID)
|
rp, hc, err := svc.Get(context.Background(), clientID)
|
||||||
if err != nil {
|
assert.NoError(t, err)
|
||||||
t.Fatalf("Get failed: %v", err)
|
assert.Equal(t, "Hydra Name", rp.Name)
|
||||||
}
|
assert.Equal(t, "Hydra Name", hc.ClientName)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRelyingPartyService_Update_Success(t *testing.T) {
|
func TestRelyingPartyService_Update_Success(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockKeto := new(MockKetoServiceShared)
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
clientID := "client-123"
|
clientID := "client-123"
|
||||||
|
|
||||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -235,20 +153,17 @@ func TestRelyingPartyService_Update_Success(t *testing.T) {
|
|||||||
HTTPClient: mockHydraClient(hydraHandler),
|
HTTPClient: mockHydraClient(hydraHandler),
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||||
|
|
||||||
updateReq := domain.HydraClient{ClientName: "New Name"}
|
updateReq := domain.HydraClient{ClientName: "New Name"}
|
||||||
rp, err := svc.Update(context.Background(), clientID, updateReq)
|
rp, err := svc.Update(context.Background(), clientID, updateReq)
|
||||||
if err != nil {
|
assert.NoError(t, err)
|
||||||
t.Fatalf("Update failed: %v", err)
|
assert.Equal(t, "New Name", rp.Name)
|
||||||
}
|
|
||||||
if rp.Name != "New Name" {
|
|
||||||
t.Errorf("expected New Name, got %s", rp.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockKeto := new(MockKetoServiceShared)
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
clientID := "client-123"
|
clientID := "client-123"
|
||||||
tenantID := "tenant-1"
|
tenantID := "tenant-1"
|
||||||
|
|
||||||
@@ -273,13 +188,14 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
|||||||
HTTPClient: mockHydraClient(hydraHandler),
|
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)
|
err := svc.Delete(context.Background(), clientID)
|
||||||
if err != nil {
|
assert.NoError(t, err)
|
||||||
t.Fatalf("Delete failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mockKeto.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,18 @@ type TenantService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tenantService struct {
|
type tenantService struct {
|
||||||
repo repository.TenantRepository
|
repo repository.TenantRepository
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
keto KetoService
|
keto KetoService
|
||||||
|
outboxRepo repository.KetoOutboxRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository) TenantService {
|
func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository, outboxRepo repository.KetoOutboxRepository) TenantService {
|
||||||
return &tenantService{repo: repo, userRepo: userRepo}
|
return &tenantService{
|
||||||
|
repo: repo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
outboxRepo: outboxRepo,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) SetKetoService(keto KetoService) {
|
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)
|
// 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 {
|
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)
|
// 2. 직접 소유자(조직장)인 테넌트 ID 목록 (Tenant:ID#owners@User:ID)
|
||||||
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
|
directOwnerIDs, err := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||||
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
|
slog.Error("Failed to list owned tenants", "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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 합산 및 중복 제거
|
// 합산 및 중복 제거
|
||||||
allIDsMap := make(map[string]bool)
|
allIDsMap := make(map[string]bool)
|
||||||
for _, id := range directTenantIDs {
|
for _, id := range directAdminIDs {
|
||||||
allIDsMap[id] = true
|
allIDsMap[id] = true
|
||||||
}
|
}
|
||||||
for _, id := range ownedGroupIDs {
|
for _, id := range directOwnerIDs {
|
||||||
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
|
|
||||||
}
|
|
||||||
for _, id := range inheritedTenantIDs {
|
|
||||||
allIDsMap[id] = true
|
allIDsMap[id] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: 상속된 권한(부모의 어드민이 자식의 어드민)은 Keto의 OPL에서 처리되므로,
|
||||||
|
// 특정 유저가 'view' 또는 'manage' 권한을 가진 테넌트를 모두 찾으려면
|
||||||
|
// Keto의 'expand' 또는 'list objects' 기능을 더 고도화하거나,
|
||||||
|
// 여기서는 직접 할당된 부모 테넌트를 기준으로 하위 테넌트 정보를 추가 조회하는 로직이 필요할 수 있습니다.
|
||||||
|
// 우선 직접 할당된 테넌트들만 반환합니다.
|
||||||
|
|
||||||
allIDs := make([]string, 0, len(allIDsMap))
|
allIDs := make([]string, 0, len(allIDsMap))
|
||||||
for id := range allIDsMap {
|
for id := range allIDsMap {
|
||||||
allIDs = append(allIDs, id)
|
allIDs = append(allIDs, id)
|
||||||
@@ -125,6 +106,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
|||||||
|
|
||||||
// 2. Create Tenant
|
// 2. Create Tenant
|
||||||
tenant := &domain.Tenant{
|
tenant := &domain.Tenant{
|
||||||
|
Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration
|
||||||
Name: name,
|
Name: name,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Description: description,
|
Description: description,
|
||||||
@@ -135,6 +117,17 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
|||||||
return nil, err
|
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)
|
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil {
|
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{
|
tenant := &domain.Tenant{
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
Name: name,
|
Name: name,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Description: description,
|
Description: description,
|
||||||
@@ -188,21 +182,22 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Keto] Sync relation
|
// [Keto] Sync relation via Outbox
|
||||||
if s.keto != nil {
|
if s.outboxRepo != nil {
|
||||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
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
|
// Check if user already exists in our Read-Model
|
||||||
if s.userRepo != nil {
|
if s.userRepo != nil {
|
||||||
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
||||||
if err == nil && user != nil {
|
if err == nil && user != nil {
|
||||||
// User exists, assign Admin role in Keto
|
// User exists, assign Admin role in Keto via Outbox
|
||||||
err = s.keto.CreateRelation(ctx, "Tenant", tenant.ID, "admin", "User:"+user.ID)
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
if err != nil {
|
Namespace: "Tenant",
|
||||||
slog.Error("Failed to assign tenant admin in Keto", "tenant", tenant.ID, "user", user.ID, "error", err)
|
Object: tenant.ID,
|
||||||
} else {
|
Relation: "admins",
|
||||||
slog.Info("Assigned tenant admin in Keto", "tenant", tenant.ID, "user", user.ID)
|
Subject: "User:" + user.ID,
|
||||||
}
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail)
|
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
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
|
||||||
|
|
||||||
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||||
mockRepo := new(MockTenantRepoForSvc)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
svc := NewTenantService(mockRepo, nil)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
svc := NewTenantService(mockRepo, nil, mockOutbox)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
name := "New Tenant"
|
name := "New Tenant"
|
||||||
@@ -142,7 +141,8 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
|||||||
|
|
||||||
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
||||||
mockRepo := new(MockTenantRepoForSvc)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
svc := NewTenantService(mockRepo, nil)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
svc := NewTenantService(mockRepo, nil, mockOutbox)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
name := "Public Tenant"
|
name := "Public Tenant"
|
||||||
@@ -165,8 +165,9 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
|||||||
mockRepo := new(MockTenantRepoForSvc)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
mockUserRepo := new(MockUserRepoForTenant)
|
mockUserRepo := new(MockUserRepoForTenant)
|
||||||
mockKeto := new(MockKetoSvcForTenant)
|
mockKeto := new(MockKetoSvcForTenant)
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
|
||||||
svc := NewTenantService(mockRepo, mockUserRepo)
|
svc := NewTenantService(mockRepo, mockUserRepo, mockOutbox)
|
||||||
svc.SetKetoService(mockKeto)
|
svc.SetKetoService(mockKeto)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -183,11 +184,14 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
|||||||
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
|
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
|
||||||
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
||||||
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, 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)
|
err := svc.ApproveTenant(ctx, tenantID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
mockRepo.AssertExpectations(t)
|
mockRepo.AssertExpectations(t)
|
||||||
mockUserRepo.AssertExpectations(t)
|
mockUserRepo.AssertExpectations(t)
|
||||||
mockKeto.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type userGroupService struct {
|
|||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
tenantRepo repository.TenantRepository
|
tenantRepo repository.TenantRepository
|
||||||
ketoService KetoService
|
ketoService KetoService
|
||||||
|
outboxRepo repository.KetoOutboxRepository
|
||||||
kratos *KratosAdminService
|
kratos *KratosAdminService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ func NewUserGroupService(
|
|||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
tenantRepo repository.TenantRepository,
|
tenantRepo repository.TenantRepository,
|
||||||
keto KetoService,
|
keto KetoService,
|
||||||
|
outbox repository.KetoOutboxRepository,
|
||||||
kratos *KratosAdminService,
|
kratos *KratosAdminService,
|
||||||
) UserGroupService {
|
) UserGroupService {
|
||||||
return &userGroupService{
|
return &userGroupService{
|
||||||
@@ -44,19 +46,55 @@ func NewUserGroupService(
|
|||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
tenantRepo: tenantRepo,
|
tenantRepo: tenantRepo,
|
||||||
ketoService: keto,
|
ketoService: keto,
|
||||||
|
outboxRepo: outbox,
|
||||||
kratos: kratos,
|
kratos: kratos,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userGroupService) Create(ctx context.Context, group *domain.UserGroup) error {
|
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 {
|
if err := s.repo.Create(ctx, group); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keto: UserGroup:<id>#parent_tenant@Tenant:<tid>
|
// Keto Hierarchy via Outbox: Tenant:<child_id>#parents@Tenant:<parent_id>
|
||||||
err := s.ketoService.CreateRelation(ctx, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID)
|
if s.outboxRepo != nil {
|
||||||
if err != nil {
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
slog.Error("Failed to create keto relation for user group", "error", err, "group_id", group.ID)
|
Namespace: "Tenant",
|
||||||
|
Object: group.ID,
|
||||||
|
Relation: "parents",
|
||||||
|
Subject: "Tenant:" + *parentID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -77,8 +115,8 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch members from Keto
|
// Fetch members from Keto (Tenant namespace)
|
||||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
|
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", group.ID, "members", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
|
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
|
||||||
return nil, err
|
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 each group, fetch member count from Keto
|
||||||
for i := range groups {
|
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 {
|
if err == nil {
|
||||||
// Create dummy members just to carry the count for the JSON response
|
// Create dummy members just to carry the count for the JSON response
|
||||||
groups[i].Members = make([]domain.User, len(tuples))
|
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 {
|
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||||
// Keto: UserGroup:<groupID>#members@User:<userID>
|
// Keto via Outbox: Tenant:<groupID>#members@User:<userID>
|
||||||
err := s.ketoService.CreateRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
|
if s.outboxRepo != nil {
|
||||||
if err != nil {
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
slog.Error("Failed to sync group membership to keto", "error", err, "group", groupID, "user", userID)
|
Namespace: "Tenant",
|
||||||
return err
|
Object: groupID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
|
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
|
||||||
// Keto: Delete relation
|
// Keto via Outbox: Delete relation
|
||||||
err := s.ketoService.DeleteRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
|
if s.outboxRepo != nil {
|
||||||
if err != nil {
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
slog.Error("Failed to remove group membership from keto", "error", err, "group", groupID, "user", userID)
|
Namespace: "Tenant",
|
||||||
return err
|
Object: groupID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
|
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
|
||||||
// Query: namespace=Tenant, subject=UserGroup:groupID#members
|
// Query: namespace=Tenant, subject=Tenant:groupID#members
|
||||||
subject := "UserGroup:" + groupID + "#members"
|
subject := "Tenant:" + groupID + "#members"
|
||||||
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
|
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
|
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 {
|
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||||
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
|
// Keto via Outbox: Tenant:<tenantID>#<relation>@Tenant:<groupID>#members
|
||||||
// This means all members of the group have the relation on the tenant.
|
if s.outboxRepo != nil {
|
||||||
subject := "UserGroup:" + groupID + "#members"
|
subject := "Tenant:" + groupID + "#members"
|
||||||
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
if err != nil {
|
Namespace: "Tenant",
|
||||||
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
|
Object: tenantID,
|
||||||
return err
|
Relation: relation,
|
||||||
|
Subject: subject,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||||
subject := "UserGroup:" + groupID + "#members"
|
// Keto via Outbox: Delete relation
|
||||||
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
|
if s.outboxRepo != nil {
|
||||||
if err != nil {
|
subject := "Tenant:" + groupID + "#members"
|
||||||
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
return err
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: relation,
|
||||||
|
Subject: subject,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ type MockTenantRepository struct {
|
|||||||
mock.Mock
|
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) Update(ctx context.Context, tenant *domain.Tenant) error { return nil }
|
||||||
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
|
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -98,66 +100,81 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
|
||||||
|
|
||||||
func TestUserGroupService_Create(t *testing.T) {
|
func TestUserGroupService_Create(t *testing.T) {
|
||||||
mockRepo := new(MockUserGroupRepository)
|
mockRepo := new(MockUserGroupRepository)
|
||||||
mockKeto := new(MockKetoService)
|
mockTenantRepo := new(MockTenantRepository)
|
||||||
// We don't need userRepo or tenantRepo for Create
|
mockKeto := new(MockKetoServiceShared)
|
||||||
svc := NewUserGroupService(mockRepo, nil, nil, mockKeto, nil)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil)
|
||||||
|
|
||||||
group := &domain.UserGroup{
|
group := &domain.UserGroup{
|
||||||
ID: "group-1",
|
ID: "group-1",
|
||||||
TenantID: "tenant-1",
|
TenantID: "company-1",
|
||||||
Name: "Test Group",
|
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)
|
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)
|
err := svc.Create(context.Background(), group)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
mockTenantRepo.AssertExpectations(t)
|
||||||
mockRepo.AssertExpectations(t)
|
mockRepo.AssertExpectations(t)
|
||||||
mockKeto.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserGroupService_AddMember(t *testing.T) {
|
func TestUserGroupService_AddMember(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil)
|
svc := NewUserGroupService(nil, nil, nil, nil, mockOutbox, nil)
|
||||||
|
|
||||||
groupID := "group-1"
|
groupID := "group-1"
|
||||||
userID := "user-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)
|
err := svc.AddMember(context.Background(), groupID, userID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
mockKeto.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil)
|
svc := NewUserGroupService(nil, nil, nil, nil, mockOutbox, nil)
|
||||||
|
|
||||||
groupID := "group-1"
|
groupID := "group-1"
|
||||||
tenantID := "tenant-alpha"
|
tenantID := "tenant-alpha"
|
||||||
relation := "manage"
|
relation := "manage"
|
||||||
|
|
||||||
expectedSubject := "UserGroup:" + groupID + "#members"
|
expectedSubject := "Tenant:" + groupID + "#members"
|
||||||
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, relation, expectedSubject).Return(nil)
|
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)
|
err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
mockKeto.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserGroupService_ListRoles(t *testing.T) {
|
func TestUserGroupService_ListRoles(t *testing.T) {
|
||||||
mockKeto := new(MockKetoService)
|
mockKeto := new(MockKetoServiceShared)
|
||||||
mockTenantRepo := new(MockTenantRepository)
|
mockTenantRepo := new(MockTenantRepository)
|
||||||
svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil)
|
svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil, nil)
|
||||||
|
|
||||||
groupID := "group-1"
|
groupID := "group-1"
|
||||||
subject := "UserGroup:" + groupID + "#members"
|
subject := "Tenant:" + groupID + "#members"
|
||||||
|
|
||||||
// Mock Keto relations
|
// Mock Keto relations
|
||||||
tuples := []RelationTuple{
|
tuples := []RelationTuple{
|
||||||
@@ -186,15 +203,11 @@ func TestUserGroupService_ListRoles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUserGroupService_Get_WithKratosFallback(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)
|
mockRepo := new(MockUserGroupRepository)
|
||||||
mockKeto := new(MockKetoService)
|
mockKeto := new(MockKetoServiceShared)
|
||||||
mockUserRepo := new(MockUserRepository)
|
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"
|
groupID := "group-1"
|
||||||
mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil)
|
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{
|
tuples := []RelationTuple{
|
||||||
{Object: groupID, Relation: "members", SubjectID: "User:u1"},
|
{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)
|
mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil)
|
||||||
|
|
||||||
group, err := svc.Get(context.Background(), groupID)
|
group, err := svc.Get(context.Background(), groupID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, group)
|
assert.NotNil(t, group)
|
||||||
// Members should be empty since Kratos is nil in this test setup
|
|
||||||
assert.Len(t, group.Members, 0)
|
assert.Len(t, group.Members, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,43 +2,23 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
|||||||
|
|
||||||
class User implements Namespace {}
|
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 {
|
class Tenant implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
admins: (User | SubjectSet<UserGroup, "members">)[]
|
owners: User[]
|
||||||
members: (User | SubjectSet<UserGroup, "members">)[]
|
admins: (User | SubjectSet<Tenant, "owners">)[]
|
||||||
parent: Tenant[]
|
members: User[]
|
||||||
parent_group: TenantGroup[]
|
parents: Tenant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
view: (ctx: Context): boolean =>
|
view: (ctx: Context): boolean =>
|
||||||
this.related.members.includes(ctx.subject) ||
|
this.related.members.includes(ctx.subject) ||
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
this.related.parent.traverse((p) => p.permits.view(ctx)) ||
|
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||||
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
|
||||||
|
|
||||||
manage: (ctx: Context): boolean =>
|
manage: (ctx: Context): boolean =>
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
this.related.parent.traverse((p) => p.permits.manage(ctx)) ||
|
this.related.parents.traverse((p) => p.permits.manage(ctx)),
|
||||||
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
|
||||||
|
|
||||||
create_subtenant: (ctx: Context): boolean =>
|
create_subtenant: (ctx: Context): boolean =>
|
||||||
this.permits.manage(ctx)
|
this.permits.manage(ctx)
|
||||||
@@ -47,24 +27,30 @@ class Tenant implements Namespace {
|
|||||||
|
|
||||||
class RelyingParty implements Namespace {
|
class RelyingParty implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
owners: (User | SubjectSet<UserGroup, "members">)[]
|
admins: User[]
|
||||||
parent_tenant: Tenant[]
|
parents: Tenant[]
|
||||||
|
access: (User | SubjectSet<Tenant, "members"> | SubjectSet<System, "authenticated_users">)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
view: (ctx: Context): boolean =>
|
view: (ctx: Context): boolean =>
|
||||||
this.related.owners.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
this.related.parent_tenant.traverse((t) => t.permits.view(ctx)),
|
this.related.parents.traverse((t) => t.permits.view(ctx)),
|
||||||
|
|
||||||
manage: (ctx: Context): boolean =>
|
manage: (ctx: Context): boolean =>
|
||||||
this.related.owners.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
this.related.parent_tenant.traverse((t) => t.permits.manage(ctx))
|
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 {
|
class System implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
super_admins: User[]
|
super_admins: User[]
|
||||||
|
authenticated_users: User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
|
|||||||
Reference in New Issue
Block a user