forked from baron/baron-sso
SSOT 전환
This commit is contained in:
@@ -249,9 +249,9 @@ func main() {
|
||||
tenantService := service.NewTenantService(tenantRepo)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
relyingPartyRepo := repository.NewRelyingPartyRepository(db)
|
||||
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||
hydraService := service.NewHydraAdminService()
|
||||
relyingPartyService := service.NewRelyingPartyService(relyingPartyRepo, hydraService, ketoService)
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
|
||||
@@ -37,7 +37,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
&domain.ClientSecret{},
|
||||
&domain.RelyingParty{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,18 @@ package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RelyingParty represents an OAuth2 Client owner by a Tenant.
|
||||
// It maps 1:1 to a Hydra Client.
|
||||
type RelyingParty struct {
|
||||
ClientID string `gorm:"primaryKey" json:"clientId"` // Maps to Hydra Client ID
|
||||
TenantID string `gorm:"index" json:"tenantId"`
|
||||
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// We don't store OAuth2 specific config here (redirect_uris, etc.)
|
||||
// those are fetched from Hydra on demand.
|
||||
ClientID string `json:"clientId"` // Maps to Hydra Client ID
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// DeletedAt removed as it's not a DB model anymore
|
||||
}
|
||||
|
||||
func (rp *RelyingParty) TableName() string {
|
||||
return "relying_parties"
|
||||
}
|
||||
// TableName removed
|
||||
|
||||
34
backend/internal/domain/user_group.go
Normal file
34
backend/internal/domain/user_group.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserGroup represents a collection of users within a tenant.
|
||||
type UserGroup struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Relationships
|
||||
Members []User `gorm:"-" json:"members,omitempty"`
|
||||
}
|
||||
|
||||
func (ug *UserGroup) TableName() string {
|
||||
return "user_groups"
|
||||
}
|
||||
|
||||
func (ug *UserGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if ug.ID == "" {
|
||||
ug.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
94
backend/internal/handler/user_group_handler.go
Normal file
94
backend/internal/handler/user_group_handler.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type UserGroupHandler struct {
|
||||
Service service.UserGroupService
|
||||
}
|
||||
|
||||
func NewUserGroupHandler(s service.UserGroupService) *UserGroupHandler {
|
||||
return &UserGroupHandler{Service: s}
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) List(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
groups, err := h.Service.List(c.Context(), tenantID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(groups)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) Create(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
var group domain.UserGroup
|
||||
if err := c.BodyParser(&group); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
|
||||
}
|
||||
group.TenantID = tenantID
|
||||
|
||||
if err := h.Service.Create(c.Context(), &group); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(group)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
group, err := h.Service.Get(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
|
||||
}
|
||||
return c.JSON(group)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) Update(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var group domain.UserGroup
|
||||
if err := c.BodyParser(&group); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
|
||||
}
|
||||
group.ID = id
|
||||
|
||||
if err := h.Service.Update(c.Context(), &group); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(group)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) Delete(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := h.Service.Delete(c.Context(), id); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) AddMember(c *fiber.Ctx) error {
|
||||
groupID := c.Params("id")
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "userId is required"})
|
||||
}
|
||||
|
||||
if err := h.Service.AddMember(c.Context(), groupID, req.UserID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
|
||||
groupID := c.Params("id")
|
||||
userID := c.Params("userId")
|
||||
|
||||
if err := h.Service.RemoveMember(c.Context(), groupID, userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
53
backend/internal/repository/user_group_repository.go
Normal file
53
backend/internal/repository/user_group_repository.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserGroupRepository interface {
|
||||
Create(ctx context.Context, group *domain.UserGroup) error
|
||||
Update(ctx context.Context, group *domain.UserGroup) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
FindByID(ctx context.Context, id string) (*domain.UserGroup, error)
|
||||
ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
|
||||
}
|
||||
|
||||
type userGroupRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserGroupRepository(db *gorm.DB) UserGroupRepository {
|
||||
return &userGroupRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userGroupRepository) Create(ctx context.Context, group *domain.UserGroup) error {
|
||||
return r.db.WithContext(ctx).Create(group).Error
|
||||
}
|
||||
|
||||
func (r *userGroupRepository) Update(ctx context.Context, group *domain.UserGroup) error {
|
||||
return r.db.WithContext(ctx).Save(group).Error
|
||||
}
|
||||
|
||||
func (r *userGroupRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&domain.UserGroup{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *userGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
|
||||
var group domain.UserGroup
|
||||
if err := r.db.WithContext(ctx).First(&group, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (r *userGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
|
||||
var groups []domain.UserGroup
|
||||
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type UserRepository interface {
|
||||
Update(ctx context.Context, user *domain.User) error
|
||||
FindByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
||||
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
||||
List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error)
|
||||
}
|
||||
@@ -48,6 +49,18 @@ func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User,
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
if len(ids) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ type KetoService interface {
|
||||
CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error)
|
||||
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
|
||||
}
|
||||
|
||||
type ketoService struct {
|
||||
@@ -42,6 +43,55 @@ func NewKetoService() KetoService {
|
||||
}
|
||||
}
|
||||
|
||||
type RelationTuple struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Object string `json:"object"`
|
||||
Relation string `json:"relation"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
}
|
||||
|
||||
type relationTuplesResponse struct {
|
||||
RelationTuples []RelationTuple `json:"relation_tuples"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
func (s *ketoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
|
||||
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
|
||||
q := u.Query()
|
||||
if namespace != "" {
|
||||
q.Set("namespace", namespace)
|
||||
}
|
||||
if object != "" {
|
||||
q.Set("object", object)
|
||||
}
|
||||
if relation != "" {
|
||||
q.Set("relation", relation)
|
||||
}
|
||||
if subject != "" {
|
||||
q.Set("subject_id", subject)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res relationTuplesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.RelationTuples, nil
|
||||
}
|
||||
|
||||
type checkResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -18,33 +17,16 @@ type RelyingPartyService interface {
|
||||
Delete(ctx context.Context, clientID string) error
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
|
||||
return s.repo.ListAll(ctx)
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
|
||||
// Simple implementation for now, repository could be optimized with IN clause
|
||||
var allRps []domain.RelyingParty
|
||||
for _, tid := range tenantIDs {
|
||||
rps, _ := s.repo.ListByTenantID(ctx, tid)
|
||||
allRps = append(allRps, rps...)
|
||||
}
|
||||
return allRps, nil
|
||||
}
|
||||
|
||||
type relyingPartyService struct {
|
||||
repo repository.RelyingPartyRepository
|
||||
hydraService *HydraAdminService
|
||||
ketoService KetoService
|
||||
}
|
||||
|
||||
func NewRelyingPartyService(
|
||||
repo repository.RelyingPartyRepository,
|
||||
hydraService *HydraAdminService,
|
||||
ketoService KetoService,
|
||||
) RelyingPartyService {
|
||||
return &relyingPartyService{
|
||||
repo: repo,
|
||||
hydraService: hydraService,
|
||||
ketoService: ketoService,
|
||||
}
|
||||
@@ -52,104 +34,146 @@ func NewRelyingPartyService(
|
||||
|
||||
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||
// 1. Create Client in Hydra
|
||||
// Ensure metadata contains tenant_id for reference
|
||||
if client.Metadata == nil {
|
||||
client.Metadata = make(map[string]interface{})
|
||||
}
|
||||
client.Metadata["tenant_id"] = tenantID
|
||||
// Ensure description is in metadata if provided in some other way?
|
||||
// The input 'client' is domain.HydraClient. It doesn't have a separate description field.
|
||||
// Assuming caller puts description in metadata.
|
||||
|
||||
createdClient, err := s.hydraService.CreateClient(ctx, client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
||||
}
|
||||
|
||||
// 2. Create Record in DB
|
||||
rp := &domain.RelyingParty{
|
||||
ClientID: createdClient.ClientID,
|
||||
TenantID: tenantID,
|
||||
Name: createdClient.ClientName,
|
||||
Description: "", // Hydra doesn't have description field standard, maybe in metadata?
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, rp); err != nil {
|
||||
// Rollback: Delete Hydra Client
|
||||
_ = s.hydraService.DeleteClient(ctx, createdClient.ClientID)
|
||||
return nil, fmt.Errorf("failed to create relying party in db: %w", err)
|
||||
}
|
||||
|
||||
// 3. Create Relation in Keto
|
||||
// 2. Create Relation in Keto
|
||||
// RelyingParty:<client_id>#parent_tenant@Tenant:<tenant_id>
|
||||
err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create keto relation for relying party", "error", err, "client_id", createdClient.ClientID)
|
||||
// We don't rollback here, but we should probably have a background job to fix this.
|
||||
// Or return error and let caller decide? For MVP, logging error is acceptable as per issue discussion (Eventual Consistency preferred).
|
||||
// Try to cleanup Hydra client
|
||||
_ = s.hydraService.DeleteClient(ctx, createdClient.ClientID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rp, nil
|
||||
return s.mapHydraToDomain(createdClient), nil
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) {
|
||||
// Get from DB
|
||||
rp, err := s.repo.FindByID(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Get from Hydra
|
||||
hydraClient, err := s.hydraService.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return rp, hydraClient, nil
|
||||
return s.mapHydraToDomain(hydraClient), hydraClient, nil
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
|
||||
return s.repo.ListByTenantID(ctx, tenantID)
|
||||
// 1. Fetch ClientIDs from Keto
|
||||
// Subject: Tenant:<tenantID>, Relation: parent_tenant, Namespace: RelyingParty
|
||||
// Note: ListRelations checks "who has relation to subject".
|
||||
// Relation tuple: RelyingParty:cid # parent_tenant @ Tenant:tid
|
||||
// We want to find objects where subject=Tenant:tid.
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parent_tenant", "Tenant:"+tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rps []domain.RelyingParty
|
||||
for _, t := range tuples {
|
||||
// Object is "RelyingParty:clientId"
|
||||
if len(t.Object) > 13 && t.Object[:13] == "RelyingParty:" {
|
||||
clientID := t.Object[13:]
|
||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err)
|
||||
continue
|
||||
}
|
||||
if rp := s.mapHydraToDomain(client); rp != nil {
|
||||
rps = append(rps, *rp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rps, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
|
||||
var allRps []domain.RelyingParty
|
||||
for _, tid := range tenantIDs {
|
||||
rps, err := s.List(ctx, tid)
|
||||
if err == nil {
|
||||
allRps = append(allRps, rps...)
|
||||
}
|
||||
}
|
||||
return allRps, nil
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||
// Update Hydra
|
||||
updatedClient, err := s.hydraService.UpdateClient(ctx, clientID, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update DB
|
||||
rp, err := s.repo.FindByID(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rp.Name = updatedClient.ClientName
|
||||
// Update other fields if necessary
|
||||
|
||||
if err := s.repo.Update(ctx, rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rp, nil
|
||||
return s.mapHydraToDomain(updatedClient), nil
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error {
|
||||
// Delete from DB
|
||||
if err := s.repo.Delete(ctx, clientID); err != nil {
|
||||
// 1. Get client to find tenantID (for Keto cleanup)
|
||||
client, err := s.hydraService.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return err // Or ignore if not found?
|
||||
}
|
||||
tenantID := ""
|
||||
if client.Metadata != nil {
|
||||
if tid, ok := client.Metadata["tenant_id"].(string); ok {
|
||||
tenantID = tid
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete from Hydra
|
||||
if err := s.hydraService.DeleteClient(ctx, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete from Hydra
|
||||
if err := s.hydraService.DeleteClient(ctx, clientID); err != nil {
|
||||
slog.Error("Failed to delete hydra client", "error", err, "client_id", clientID)
|
||||
// Proceeding...
|
||||
// 3. Delete from Keto
|
||||
if tenantID != "" {
|
||||
_ = s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID)
|
||||
}
|
||||
|
||||
// Delete from Keto (Optional, but good practice to clean up)
|
||||
// We might not know the tenant ID here without querying DB first, but if DB is deleted, we might miss it.
|
||||
//Ideally, we should query DB first.
|
||||
// But `DeleteRelation` requires specific object/relation/subject.
|
||||
// If we want to delete ALL relations for this object, Keto API supports that?
|
||||
// `DeleteRelation` in our service wrapper is specific.
|
||||
// We can skip explicit Keto deletion for now as orphaned tuples are less critical than orphaned resources.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
rp := &domain.RelyingParty{
|
||||
ClientID: client.ClientID,
|
||||
Name: client.ClientName,
|
||||
}
|
||||
if client.Metadata != nil {
|
||||
if tid, ok := client.Metadata["tenant_id"].(string); ok {
|
||||
rp.TenantID = tid
|
||||
}
|
||||
if desc, ok := client.Metadata["description"].(string); ok {
|
||||
rp.Description = desc
|
||||
}
|
||||
}
|
||||
return rp
|
||||
}
|
||||
|
||||
|
||||
121
backend/internal/service/user_group_service.go
Normal file
121
backend/internal/service/user_group_service.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type UserGroupService interface {
|
||||
Create(ctx context.Context, group *domain.UserGroup) error
|
||||
Update(ctx context.Context, group *domain.UserGroup) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
Get(ctx context.Context, id string) (*domain.UserGroup, error)
|
||||
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
|
||||
|
||||
// Member Management with Keto Sync
|
||||
AddMember(ctx context.Context, groupID, userID string) error
|
||||
RemoveMember(ctx context.Context, groupID, userID string) error
|
||||
}
|
||||
|
||||
type userGroupService struct {
|
||||
repo repository.UserGroupRepository
|
||||
userRepo repository.UserRepository
|
||||
ketoService KetoService
|
||||
}
|
||||
|
||||
func NewUserGroupService(repo repository.UserGroupRepository, userRepo repository.UserRepository, keto KetoService) UserGroupService {
|
||||
return &userGroupService{
|
||||
repo: repo,
|
||||
userRepo: userRepo,
|
||||
ketoService: keto,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *userGroupService) Create(ctx context.Context, group *domain.UserGroup) error {
|
||||
if err := s.repo.Create(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Keto: UserGroup:<id>#parent_tenant@Tenant:<tid>
|
||||
err := s.ketoService.CreateRelation(ctx, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create keto relation for user group", "error", err, "group_id", group.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) Update(ctx context.Context, group *domain.UserGroup) error {
|
||||
return s.repo.Update(ctx, group)
|
||||
}
|
||||
|
||||
func (s *userGroupService) Delete(ctx context.Context, id string) error {
|
||||
// Optional: Delete relations in Keto before DB delete
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) {
|
||||
group, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch members from Keto
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
|
||||
// Return group without members rather than failing?
|
||||
// But if we fail here, we might hide partial failure. Let's log and proceed or return error?
|
||||
// For now, let's proceed with empty members to avoid blocking UI if keto is down?
|
||||
// No, SSOT is Keto. If Keto is down, we can't show members.
|
||||
// Returning error might be safer.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userIDs []string
|
||||
for _, t := range tuples {
|
||||
// SubjectID is like "User:uuid"
|
||||
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
||||
userIDs = append(userIDs, t.SubjectID[5:])
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) > 0 {
|
||||
members, err := s.userRepo.FindByIDs(ctx, userIDs)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch member details from db", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
group.Members = members
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
|
||||
return s.repo.ListByTenantID(ctx, tenantID)
|
||||
}
|
||||
|
||||
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||
// Keto: UserGroup:<groupID>#members@User:<userID>
|
||||
err := s.ketoService.CreateRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to sync group membership to keto", "error", err, "group", groupID, "user", userID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
|
||||
// Keto: Delete relation
|
||||
err := s.ketoService.DeleteRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to remove group membership from keto", "error", err, "group", groupID, "user", userID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user