1
0
forked from baron/baron-sso

Ory Keto ReBAC Policy & Relation Tuple Architecture

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

View File

@@ -88,6 +88,7 @@ type AuthHandler struct {
Hydra *service.HydraAdminService
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository
}
@@ -147,7 +148,7 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
return false, int(interval.Seconds())
}
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
return &AuthHandler{
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
@@ -159,6 +160,7 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
Hydra: service.NewHydraAdminService(),
TenantService: tenantService,
KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
ConsentRepo: consentRepo,
}
@@ -496,20 +498,20 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
} else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
// [Keto] Sync user-tenant relationship via Outbox
if h.KetoOutboxRepo != nil && u.TenantID != nil {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "members",
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}(localUser)
}
// [Keto] Sync user-tenant relationship
if h.KetoService != nil && tenantID != nil {
go func() {
err := h.KetoService.CreateRelation(context.Background(), "Tenant", *tenantID, "members", providerID)
if err != nil {
slog.Error("[Signup] Failed to sync membership to Keto", "userID", providerID, "tenantID", *tenantID, "error", err)
}
}()
}
return c.JSON(fiber.Map{
"success": true,
"message": "User registered successfully",

View File

@@ -0,0 +1,41 @@
package handler
import (
"baron-sso-backend/internal/service"
"log/slog"
"github.com/gofiber/fiber/v2"
)
type OrgChartHandler struct {
Service service.OrgChartService
}
func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
return &OrgChartHandler{Service: s}
}
func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
tenantID := c.Params("tenantId")
if tenantID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "failed to get file from form"})
}
f, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open file"})
}
defer f.Close()
if err := h.Service.ImportCSV(c.Context(), tenantID, f); err != nil {
slog.Error("Failed to import CSV", "error", err, "tenantID", tenantID)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Import completed successfully"})
}

View File

@@ -16,14 +16,16 @@ type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin *service.KratosAdminService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, kratos *service.KratosAdminService) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos *service.KratosAdminService) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
}
}
@@ -324,7 +326,7 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
}
// Fetch admins from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "")
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -375,8 +377,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
}
if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
@@ -389,8 +397,14 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
}
if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
return c.SendStatus(fiber.StatusNoContent)

View File

@@ -14,20 +14,22 @@ import (
)
type UserHandler struct {
KratosAdmin *service.KratosAdminService
OryProvider *service.OryProvider
TenantService service.TenantService
KetoService service.KetoService
UserRepo repository.UserRepository
KratosAdmin *service.KratosAdminService
OryProvider *service.OryProvider
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
}
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *UserHandler {
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler {
return &UserHandler{
KratosAdmin: kratosAdmin,
OryProvider: oryProvider,
TenantService: tenantService,
KetoService: ketoService,
UserRepo: userRepo,
KratosAdmin: kratosAdmin,
OryProvider: oryProvider,
TenantService: tenantService,
KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
}
}
@@ -315,21 +317,36 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}(localUser)
}
// [Keto] Sync relations
if h.KetoService != nil {
go func() {
ctx := context.Background()
// 1. Tenant Membership
if localUser.TenantID != nil {
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID)
}
// 2. Role Specifics
if role == domain.RoleSuperAdmin {
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", identityID)
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "admins", identityID)
}
}()
// [Keto] Sync relations via Outbox
if h.KetoOutboxRepo != nil {
// 1. Tenant Membership
if localUser.TenantID != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "members",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
}
// 2. Role Specifics
if role == domain.RoleSuperAdmin {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "admins",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
}
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
@@ -489,25 +506,50 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.UserRepo.Update(ctx, u); err == nil {
// [Keto Sync on Role Change]
if h.KetoService != nil && rRole != nil && *rRole != oRole {
// [Keto Sync on Role Change] via Outbox
if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
uID := u.ID
newR := *rRole
if oRole == domain.RoleSuperAdmin {
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionDelete,
})
} else if oRole == domain.RoleTenantAdmin && oTenantID != "" {
_ = h.KetoService.DeleteRelation(ctx, "Tenant", oTenantID, "admins", uID)
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: oTenantID,
Relation: "admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionDelete,
})
}
if newR == domain.RoleSuperAdmin {
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID)
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionCreate,
})
} else if newR == domain.RoleTenantAdmin && u.TenantID != nil {
_ = h.KetoService.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", uID)
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionCreate,
})
}
}
} else {
@@ -552,16 +594,17 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// [Keto] Cleanup relations (Best effort)
if h.KetoService != nil {
go func(uID string) {
ctx := context.Background()
// Fetch user from DB before cleanup if needed, but here we cleanup common namespaces
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
// If we had more complex relations, we would query Keto first or use user metadata
slog.Info("Keto relations cleaned up for user", "userID", uID)
}(userID)
// [Keto] Cleanup relations via Outbox
if h.KetoOutboxRepo != nil {
ctx := context.Background()
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
// Additional cleanup for tenants could be added here if we keep track of user's current tenants
}
return c.SendStatus(fiber.StatusNoContent)