package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "fmt" "log/slog" "strings" ) type RelyingPartyService interface { Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) ListAll(ctx context.Context) ([]domain.RelyingParty, error) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) Delete(ctx context.Context, clientID string) error } type relyingPartyService struct { hydraService *HydraAdminService ketoService KetoService outboxRepo repository.KetoOutboxRepository } var defaultRelyingPartyOperatorRelations = []string{ "admins", "creator", "config_editor", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator", } func NewRelyingPartyService( hydraService *HydraAdminService, ketoService KetoService, outboxRepo repository.KetoOutboxRepository, ) RelyingPartyService { return &relyingPartyService{ hydraService: hydraService, ketoService: ketoService, outboxRepo: outboxRepo, } } func extractRelyingPartyCreatorSubject(client *domain.HydraClient) string { if client == nil || client.Metadata == nil { return "" } raw, _ := client.Metadata["user_id"].(string) raw = strings.TrimSpace(raw) if raw == "" { return "" } return "User:" + raw } func (s *relyingPartyService) enqueueRelyingPartyTuple(ctx context.Context, action, object, relation, subject string) { if s.outboxRepo == nil || strings.TrimSpace(object) == "" || strings.TrimSpace(relation) == "" || strings.TrimSpace(subject) == "" { return } _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "RelyingParty", Object: object, Relation: relation, Subject: subject, Action: action, }) } func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Context, action string, client *domain.HydraClient, tenantID string) { if client == nil { return } tenantID = strings.TrimSpace(tenantID) if tenantID != "" { s.enqueueRelyingPartyTuple(ctx, action, client.ClientID, "parents", "Tenant:"+tenantID) } creatorSubject := extractRelyingPartyCreatorSubject(client) if creatorSubject == "" { return } for _, relation := range defaultRelyingPartyOperatorRelations { s.enqueueRelyingPartyTuple(ctx, action, client.ClientID, relation, creatorSubject) } } func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) { // 1. Create Client in Hydra if client.Metadata == nil { client.Metadata = make(map[string]any) } client.Metadata["tenant_id"] = tenantID createdClient, err := s.hydraService.CreateClient(ctx, client) if err != nil { return nil, fmt.Errorf("failed to create hydra client: %w", err) } // 2. Create default relations in Keto via Outbox. s.enqueueDefaultRelyingPartyRelations(ctx, domain.KetoOutboxActionCreate, createdClient, tenantID) return s.mapHydraToDomain(createdClient), nil } func (s *relyingPartyService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) { hydraClient, err := s.hydraService.GetClient(ctx, clientID) if err != nil { return nil, nil, err } return s.mapHydraToDomain(hydraClient), hydraClient, nil } func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { // 1. Fetch ClientIDs from Keto // Relation tuple: RelyingParty:cid # parents @ Tenant:tid tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parents", "Tenant:"+tenantID) if err != nil { return nil, err } var rps []domain.RelyingParty for _, t := range tuples { clientID := t.Object client, err := s.hydraService.GetClient(ctx, clientID) if err != nil { slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err) continue } if rp := s.mapHydraToDomain(client); rp != nil { rps = append(rps, *rp) } } return rps, nil } func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { 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) { updatedClient, err := s.hydraService.UpdateClient(ctx, clientID, client) if err != nil { return nil, err } return s.mapHydraToDomain(updatedClient), nil } func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error { // 1. Get client to find tenantID (for Keto cleanup) client, err := s.hydraService.GetClient(ctx, clientID) if err != nil { return err } 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 } // 3. Delete default relations from Keto via Outbox. s.enqueueDefaultRelyingPartyRelations(ctx, domain.KetoOutboxActionDelete, client, tenantID) 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 }