forked from baron/baron-sso
Merge remote-tracking branch 'origin/main'
This commit is contained in:
131
backend/internal/service/keto_service.go
Normal file
131
backend/internal/service/keto_service.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type ketoService struct {
|
||||
readURL string
|
||||
writeURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewKetoService() KetoService {
|
||||
readURL := os.Getenv("KETO_READ_URL")
|
||||
if readURL == "" {
|
||||
readURL = "http://keto:4466"
|
||||
}
|
||||
writeURL := os.Getenv("KETO_WRITE_URL")
|
||||
if writeURL == "" {
|
||||
writeURL = "http://keto:4467"
|
||||
}
|
||||
|
||||
return &ketoService{
|
||||
readURL: readURL,
|
||||
writeURL: writeURL,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type checkResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples/check", s.readURL))
|
||||
q := u.Query()
|
||||
q.Set("namespace", namespace)
|
||||
q.Set("object", object)
|
||||
q.Set("relation", relation)
|
||||
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 false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return false, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return false, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res checkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return res.Allowed, nil
|
||||
}
|
||||
|
||||
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
|
||||
payload := map[string]interface{}{
|
||||
"namespace": namespace,
|
||||
"object": object,
|
||||
"relation": relation,
|
||||
"subject_id": subject,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
resBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
||||
}
|
||||
|
||||
slog.Info("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL))
|
||||
q := u.Query()
|
||||
q.Set("namespace", namespace)
|
||||
q.Set("object", object)
|
||||
q.Set("relation", relation)
|
||||
q.Set("subject_id", subject)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
resBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
||||
}
|
||||
|
||||
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
@@ -3,28 +3,43 @@ package service
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TenantService interface {
|
||||
RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error)
|
||||
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||
ApproveTenant(ctx context.Context, id string) error
|
||||
SetKetoService(keto KetoService) // 추가
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
repo repository.TenantRepository
|
||||
keto KetoService
|
||||
}
|
||||
|
||||
func NewTenantService(repo repository.TenantRepository) TenantService {
|
||||
return &tenantService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *tenantService) SetKetoService(keto KetoService) {
|
||||
s.keto = keto
|
||||
}
|
||||
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// 1. Check if slug exists
|
||||
existing, err := s.repo.FindBySlug(ctx, slug)
|
||||
if err == nil && existing != nil {
|
||||
@@ -39,26 +54,95 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
Status: "active",
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, tenant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Add Domains
|
||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||
for _, d := range domains {
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil {
|
||||
slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err)
|
||||
// Continue adding other domains? Or fail? For now, log and continue.
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.FindBySlug(ctx, slug) // Return with preloaded domains
|
||||
return s.repo.FindBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// Verify that adminEmail domain matches the requested domainName
|
||||
parts := strings.Split(adminEmail, "@")
|
||||
if len(parts) != 2 || parts[1] != domainName {
|
||||
return nil, errors.New("admin email domain must match the tenant domain")
|
||||
}
|
||||
|
||||
tenant := &domain.Tenant{
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
Status: domain.TenantStatusPending,
|
||||
Config: domain.JSONMap{"adminEmail": adminEmail},
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, tenant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add Domain as unverified
|
||||
// TODO: Create a more nuanced AddDomain that takes 'verified' param
|
||||
// For now, Repo.AddDomain sets verified=true. I should fix Repo or just manually do it here if needed.
|
||||
// Let's fix Repo later.
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, domainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
tenant, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tenant.Status = domain.TenantStatusActive
|
||||
if err := s.repo.Update(ctx, tenant); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// [Keto] Sync relation
|
||||
if s.keto != nil {
|
||||
// 테넌트 자체를 정의 (Zanzibar style)
|
||||
// 만약 신청 시 관리자 이메일이 있었다면 해당 사용자를 찾아 admin 권한 부여 시도
|
||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||
slog.Info("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||
// 여기서는 나중에 사용자가 가입할 때 처리하거나, 이미 가입된 사용자인지 확인 필요
|
||||
// 우선 테넌트 관리자 관계 생성 로직은 사용자 가입/역할 변경 시점에 주로 발생하도록 설계
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||
return s.repo.FindByDomain(ctx, emailDomain)
|
||||
tenant, err := s.repo.FindByDomain(ctx, emailDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only return ACTIVE tenants for auto-assignment
|
||||
if tenant.Status != domain.TenantStatusActive {
|
||||
return nil, errors.New("tenant is not active")
|
||||
}
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
|
||||
Reference in New Issue
Block a user