forked from baron/baron-sso
refactor: backend tenant_group 제거 및 관련 정리
This commit is contained in:
@@ -1,32 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TenantGroup represents a collection of tenants.
|
|
||||||
type TenantGroup struct {
|
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
|
||||||
Name string `gorm:"not null" json:"name"`
|
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Tenants []Tenant `gorm:"foreignKey:TenantGroupID" json:"tenants,omitempty"`
|
|
||||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tg *TenantGroup) TableName() string {
|
|
||||||
return "tenant_groups"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tg *TenantGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
|
||||||
if tg.ID == "" {
|
|
||||||
tg.ID = uuid.NewString()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"baron-sso-backend/internal/service"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TenantGroupHandler struct {
|
|
||||||
Service service.TenantGroupService
|
|
||||||
UserService *service.KratosAdminService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
|
|
||||||
return &TenantGroupHandler{Service: svc, UserService: userSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
type tenantGroupSummary struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Tenants []tenantSummary `json:"tenants,omitempty"`
|
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
UpdatedAt string `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
|
|
||||||
limit := c.QueryInt("limit", 50)
|
|
||||||
offset := c.QueryInt("offset", 0)
|
|
||||||
|
|
||||||
groups, total, err := h.Service.ListGroups(c.Context(), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tenantGroupSummary, 0, len(groups))
|
|
||||||
for _, g := range groups {
|
|
||||||
items = append(items, mapTenantGroupSummary(g))
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) GetGroup(c *fiber.Ctx) error {
|
|
||||||
id := c.Params("id")
|
|
||||||
group, err := h.Service.GetGroup(c.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
|
|
||||||
}
|
|
||||||
return c.JSON(mapTenantGroupSummary(*group))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) CreateGroup(c *fiber.Ctx) error {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
||||||
}
|
|
||||||
|
|
||||||
group, err := h.Service.CreateGroup(c.Context(), req.Name, req.Slug, req.Description)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(mapTenantGroupSummary(*group))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) UpdateGroup(c *fiber.Ctx) error {
|
|
||||||
id := c.Params("id")
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
||||||
}
|
|
||||||
|
|
||||||
group, err := h.Service.UpdateGroup(c.Context(), id, req.Name, req.Description)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(mapTenantGroupSummary(*group))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) DeleteGroup(c *fiber.Ctx) error {
|
|
||||||
id := c.Params("id")
|
|
||||||
if err := h.Service.DeleteGroup(c.Context(), id); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) AddTenantToGroup(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
tenantID := c.Params("tenantId")
|
|
||||||
|
|
||||||
if err := h.Service.AddTenantToGroup(c.Context(), groupID, tenantID); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(fiber.Map{"message": "tenant added to group"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
tenantID := c.Params("tenantId")
|
|
||||||
|
|
||||||
if err := h.Service.RemoveTenantFromGroup(c.Context(), groupID, tenantID); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(fiber.Map{"message": "tenant removed from group"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) ListAdmins(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
userIDs, err := h.Service.ListGroupAdmins(c.Context(), groupID)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
admins := make([]adminInfo, 0, len(userIDs))
|
|
||||||
for _, uid := range userIDs {
|
|
||||||
identity, err := h.UserService.GetIdentity(c.Context(), uid)
|
|
||||||
if err == nil && identity != nil {
|
|
||||||
name, _ := identity.Traits["name"].(string)
|
|
||||||
email, _ := identity.Traits["email"].(string)
|
|
||||||
admins = append(admins, adminInfo{
|
|
||||||
ID: uid,
|
|
||||||
Name: name,
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Fallback if identity not found in Kratos
|
|
||||||
admins = append(admins, adminInfo{ID: uid})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(admins)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) AddAdmin(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
userID := c.Params("userId")
|
|
||||||
|
|
||||||
if err := h.Service.AddGroupAdmin(c.Context(), groupID, userID); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(fiber.Map{"message": "admin added to group"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) RemoveAdmin(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
userID := c.Params("userId")
|
|
||||||
|
|
||||||
if err := h.Service.RemoveGroupAdmin(c.Context(), groupID, userID); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(fiber.Map{"message": "admin removed from group"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapTenantGroupSummary(g domain.TenantGroup) tenantGroupSummary {
|
|
||||||
tenants := make([]tenantSummary, 0, len(g.Tenants))
|
|
||||||
for _, t := range g.Tenants {
|
|
||||||
tenants = append(tenants, mapTenantSummary(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
return tenantGroupSummary{
|
|
||||||
ID: g.ID,
|
|
||||||
Name: g.Name,
|
|
||||||
Slug: g.Slug,
|
|
||||||
Description: g.Description,
|
|
||||||
Tenants: tenants,
|
|
||||||
Config: g.Config,
|
|
||||||
CreatedAt: g.CreatedAt.Format(time.RFC3339),
|
|
||||||
UpdatedAt: g.UpdatedAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"baron-sso-backend/internal/middleware"
|
|
||||||
"baron-sso-backend/internal/service"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reusing MockKetoService from previous step or defining here if needed
|
|
||||||
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 {
|
|
||||||
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
|
||||||
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
|
|
||||||
args := m.Called(ctx, namespace, object, relation, subject)
|
|
||||||
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
|
||||||
args := m.Called(ctx, namespace, relation, subject)
|
|
||||||
return args.Get(0).([]string), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockAuthHandler implements middleware.AuthProfileProvider
|
|
||||||
type MockAuthHandler struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockAuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
|
||||||
args := m.Called(c)
|
|
||||||
return args.Get(0).(*domain.UserProfileResponse), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequireKetoPermission_Tenant_AuditContext(t *testing.T) {
|
|
||||||
app := fiber.New()
|
|
||||||
mockKeto := new(MockKetoService)
|
|
||||||
mockAuth := new(MockAuthHandler)
|
|
||||||
|
|
||||||
config := middleware.RBACConfig{
|
|
||||||
AuthHandler: mockAuth,
|
|
||||||
KetoService: mockKeto,
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := "user-1"
|
|
||||||
tenantID := "tenant-abc"
|
|
||||||
|
|
||||||
// Mock user profile
|
|
||||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
|
||||||
ID: userID,
|
|
||||||
Role: domain.RoleTenantAdmin,
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
// Mock Keto: Allow access
|
|
||||||
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "manage").Return(true, nil)
|
|
||||||
|
|
||||||
// Route with middleware
|
|
||||||
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "manage"), func(c *fiber.Ctx) error {
|
|
||||||
// Verify that tenant_id was injected into Locals for audit log
|
|
||||||
assert.Equal(t, tenantID, c.Locals("tenant_id"))
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Execute
|
|
||||||
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
|
|
||||||
resp, _ := app.Test(req)
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
mockKeto.AssertExpectations(t)
|
|
||||||
mockAuth.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequireKetoPermission_Deny(t *testing.T) {
|
|
||||||
app := fiber.New()
|
|
||||||
mockKeto := new(MockKetoService)
|
|
||||||
mockAuth := new(MockAuthHandler)
|
|
||||||
|
|
||||||
config := middleware.RBACConfig{
|
|
||||||
AuthHandler: mockAuth,
|
|
||||||
KetoService: mockKeto,
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := "user-bad"
|
|
||||||
tenantID := "tenant-secret"
|
|
||||||
|
|
||||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
|
||||||
ID: userID,
|
|
||||||
Role: domain.RoleUser,
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
// Mock Keto: Deny access
|
|
||||||
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "view").Return(false, nil)
|
|
||||||
|
|
||||||
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "view"), func(c *fiber.Ctx) error {
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
|
|
||||||
resp, _ := app.Test(req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TenantGroupRepository interface {
|
|
||||||
Create(ctx context.Context, group *domain.TenantGroup) error
|
|
||||||
Update(ctx context.Context, group *domain.TenantGroup) error
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
FindByID(ctx context.Context, id string) (*domain.TenantGroup, error)
|
|
||||||
List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
|
|
||||||
AddTenant(ctx context.Context, groupID, tenantID string) error
|
|
||||||
RemoveTenant(ctx context.Context, groupID, tenantID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type tenantGroupRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantGroupRepository(db *gorm.DB) TenantGroupRepository {
|
|
||||||
return &tenantGroupRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) Create(ctx context.Context, group *domain.TenantGroup) error {
|
|
||||||
return r.db.WithContext(ctx).Create(group).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) Update(ctx context.Context, group *domain.TenantGroup) error {
|
|
||||||
return r.db.WithContext(ctx).Save(group).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) Delete(ctx context.Context, id string) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&domain.TenantGroup{}, "id = ?", id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) FindByID(ctx context.Context, id string) (*domain.TenantGroup, error) {
|
|
||||||
var group domain.TenantGroup
|
|
||||||
if err := r.db.WithContext(ctx).Preload("Tenants").First(&group, "id = ?", id).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &group, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
|
|
||||||
var groups []domain.TenantGroup
|
|
||||||
var total int64
|
|
||||||
db := r.db.WithContext(ctx).Model(&domain.TenantGroup{})
|
|
||||||
db.Count(&total)
|
|
||||||
if err := db.Limit(limit).Offset(offset).Find(&groups).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return groups, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) AddTenant(ctx context.Context, groupID, tenantID string) error {
|
|
||||||
return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ?", tenantID).Update("tenant_group_id", groupID).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tenantGroupRepository) RemoveTenant(ctx context.Context, groupID, tenantID string) error {
|
|
||||||
return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ? AND tenant_group_id = ?", tenantID, groupID).Update("tenant_group_id", nil).Error
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"baron-sso-backend/internal/repository"
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TenantGroupService interface {
|
|
||||||
CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error)
|
|
||||||
GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error)
|
|
||||||
ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
|
|
||||||
UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error)
|
|
||||||
DeleteGroup(ctx context.Context, id string) error
|
|
||||||
AddTenantToGroup(ctx context.Context, groupID, tenantID string) error
|
|
||||||
RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error
|
|
||||||
AddGroupAdmin(ctx context.Context, groupID, userID string) error
|
|
||||||
RemoveGroupAdmin(ctx context.Context, groupID, userID string) error
|
|
||||||
ListGroupAdmins(ctx context.Context, groupID string) ([]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tenantGroupService struct {
|
|
||||||
repo repository.TenantGroupRepository
|
|
||||||
keto KetoService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantGroupService(repo repository.TenantGroupRepository, keto KetoService) TenantGroupService {
|
|
||||||
return &tenantGroupService{repo: repo, keto: keto}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error) {
|
|
||||||
group := &domain.TenantGroup{
|
|
||||||
Name: name,
|
|
||||||
Slug: slug,
|
|
||||||
Description: description,
|
|
||||||
}
|
|
||||||
if err := s.repo.Create(ctx, group); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return group, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error) {
|
|
||||||
return s.repo.FindByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
|
|
||||||
return s.repo.List(ctx, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error) {
|
|
||||||
group, err := s.repo.FindByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
group.Name = name
|
|
||||||
group.Description = description
|
|
||||||
if err := s.repo.Update(ctx, group); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return group, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) DeleteGroup(ctx context.Context, id string) error {
|
|
||||||
return s.repo.Delete(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) AddTenantToGroup(ctx context.Context, groupID, tenantID string) error {
|
|
||||||
if err := s.repo.AddTenant(ctx, groupID, tenantID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// [Keto] ReBAC: Tenant -> Group membership
|
|
||||||
if s.keto != nil {
|
|
||||||
err := s.keto.CreateRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to sync Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error {
|
|
||||||
if err := s.repo.RemoveTenant(ctx, groupID, tenantID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// [Keto] ReBAC: Remove Tenant -> Group membership
|
|
||||||
if s.keto != nil {
|
|
||||||
err := s.keto.DeleteRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to remove Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) AddGroupAdmin(ctx context.Context, groupID, userID string) error {
|
|
||||||
if s.keto == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.keto.CreateRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) RemoveGroupAdmin(ctx context.Context, groupID, userID string) error {
|
|
||||||
if s.keto == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.keto.DeleteRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) ListGroupAdmins(ctx context.Context, groupID string) ([]string, error) {
|
|
||||||
if s.keto == nil {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
tuples, err := s.keto.ListRelations(ctx, "TenantGroup", groupID, "admins", "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDs := make([]string, 0, len(tuples))
|
|
||||||
for _, t := range tuples {
|
|
||||||
// subject_id is "User:uuid"
|
|
||||||
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
|
||||||
userIDs = append(userIDs, t.SubjectID[5:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return userIDs, nil
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"baron-sso-backend/internal/domain"
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockTenantRepository is a mock implementation of repository.TenantRepository
|
|
||||||
type MockTenantRepository struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
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 m.Called(ctx, tenant).Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
|
|
||||||
args := m.Called(ctx, id)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
|
||||||
args := m.Called(ctx, slug)
|
|
||||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
|
|
||||||
args := m.Called(ctx, name)
|
|
||||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
|
||||||
args := m.Called(ctx, domainName)
|
|
||||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
|
||||||
args := m.Called(ctx, ids)
|
|
||||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
|
|
||||||
return m.Called(ctx, tenantID, domainName).Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) {
|
|
||||||
mockRepo := new(MockTenantRepository)
|
|
||||||
mockKeto := new(MockKetoService)
|
|
||||||
svc := &tenantService{
|
|
||||||
repo: mockRepo,
|
|
||||||
keto: mockKeto,
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := "user-123"
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 1. Mock direct tenant management (admins relation)
|
|
||||||
mockKeto.On("ListObjects", ctx, "Tenant", "admins", userID).Return([]string{"t-direct-1"}, nil)
|
|
||||||
|
|
||||||
// 2. Mock group management (admins of a group)
|
|
||||||
mockKeto.On("ListObjects", ctx, "TenantGroup", "admins", userID).Return([]string{"g-1"}, nil)
|
|
||||||
|
|
||||||
// 3. Mock tenants belonging to group g-1
|
|
||||||
mockKeto.On("ListRelations", ctx, "Tenant", "", "parent_group", "TenantGroup:g-1").Return([]RelationTuple{
|
|
||||||
{Object: "t-inherited-1", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
|
|
||||||
{Object: "t-inherited-2", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
// 4. Expect repository to fetch all unique IDs: t-direct-1, t-inherited-1, t-inherited-2
|
|
||||||
expectedIDs := []string{"t-direct-1", "t-inherited-1", "t-inherited-2"}
|
|
||||||
mockRepo.On("FindByIDs", ctx, mock.MatchedBy(func(ids []string) bool {
|
|
||||||
// Check if all expected IDs are present (order doesn't matter since we dedup via map)
|
|
||||||
foundCount := 0
|
|
||||||
for _, eid := range expectedIDs {
|
|
||||||
for _, id := range ids {
|
|
||||||
if id == eid {
|
|
||||||
foundCount++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return foundCount == len(expectedIDs) && len(ids) == len(expectedIDs)
|
|
||||||
})).Return([]domain.Tenant{
|
|
||||||
{ID: "t-direct-1", Name: "Direct Tenant"},
|
|
||||||
{ID: "t-inherited-1", Name: "Inherited Tenant 1"},
|
|
||||||
{ID: "t-inherited-2", Name: "Inherited Tenant 2"},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
// Execute
|
|
||||||
tenants, err := svc.ListManageableTenants(ctx, userID)
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, tenants, 3)
|
|
||||||
mockKeto.AssertExpectations(t)
|
|
||||||
mockRepo.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetEnv retrieves the value of the environment variable named by the key.
|
|
||||||
// It returns the value if it exists, otherwise it returns the fallback value.
|
|
||||||
// It automatically strips surrounding double quotes from the value.
|
|
||||||
func GetEnv(key, fallback string) string {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
// Strip surrounding double quotes if present
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
|
||||||
return v[1 : len(v)-1]
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user