From d8f133b1e5c674432e25e05ca337bd3b02dd6f08 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 5 Feb 2026 10:15:54 +0900 Subject: [PATCH] =?UTF-8?q?SSOT=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantGroupsPage.tsx | 221 ++++++++++++++++++ .../tenants/routes/TenantSubTenantsPage.tsx | 87 +++++++ .../tenants/routes/TenantUsersPage.tsx | 91 ++++++++ backend/cmd/server/main.go | 4 +- backend/internal/bootstrap/bootstrap.go | 2 +- backend/internal/domain/relying_party.go | 23 +- backend/internal/domain/user_group.go | 34 +++ .../internal/handler/user_group_handler.go | 94 ++++++++ .../repository/user_group_repository.go | 53 +++++ .../internal/repository/user_repository.go | 13 ++ backend/internal/service/keto_service.go | 50 ++++ .../internal/service/relying_party_service.go | 174 ++++++++------ .../internal/service/user_group_service.go | 121 ++++++++++ 13 files changed, 874 insertions(+), 93 deletions(-) create mode 100644 adminfront/src/features/tenants/routes/TenantGroupsPage.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantUsersPage.tsx create mode 100644 backend/internal/domain/user_group.go create mode 100644 backend/internal/handler/user_group_handler.go create mode 100644 backend/internal/repository/user_group_repository.go create mode 100644 backend/internal/service/user_group_service.go diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx new file mode 100644 index 00000000..4f8a7bb8 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -0,0 +1,221 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi"; +import { Badge } from "../../../components/ui/badge"; + +function TenantGroupsPage() { + const { tenantId } = useParams<{ tenantId: string }>(); + const queryClient = useQueryClient(); + + const [newGroupName, setNewGroupName] = useState(""); + const [newGroupDesc, setNewGroupNameDesc] = useState(""); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + // 그룹 목록 조회 + const groupsQuery = useQuery({ + queryKey: ["groups", tenantId], + queryFn: () => fetchGroups(tenantId!), + enabled: !!tenantId, + }); + + // 사용자 목록 조회 (멤버 추가용) + const usersQuery = useQuery({ + queryKey: ["users", { limit: 100 }], + queryFn: () => fetchUsers(100, 0), + }); + + const createMutation = useMutation({ + mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }), + onSuccess: () => { + groupsQuery.refetch(); + setNewGroupName(""); + setNewGroupNameDesc(""); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteGroup(id), + onSuccess: () => groupsQuery.refetch(), + }); + + const addMemberMutation = useMutation({ + mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId), + onSuccess: () => groupsQuery.refetch(), + }); + + const removeMemberMutation = useMutation({ + mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId), + onSuccess: () => groupsQuery.refetch(), + }); + + const handleAddMember = (groupId: string) => { + const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:"); + if (userId) { + addMemberMutation.mutate({ groupId, userId }); + } + }; + + const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId); + + return ( +
+
+ {/* 그룹 생성 폼 */} + + + + 새 그룹 생성 + + + +
+ + setNewGroupName(e.target.value)} + placeholder="예: 개발팀, 인사팀" + /> +
+
+ + setNewGroupNameDesc(e.target.value)} + placeholder="그룹 용도 설명" + /> +
+ +
+
+ + {/* 그룹 목록 */} + + +
+ User Groups + 이 테넌트에 정의된 사용자 그룹 목록입니다. +
+ +
+ + + + + NAME + MEMBERS + ACTIONS + + + + {groupsQuery.data?.map((group) => ( + setSelectedGroupId(group.id)} + > + +
+ + {group.name} +
+

{group.description}

+
+ + {group.members?.length || 0} 명 + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ + {/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} + {currentGroup && ( + + + + + [{currentGroup.name}] 멤버 관리 + + + + + + + 이름 + 이메일 + 제거 + + + + {currentGroup.members?.length === 0 && ( + 멤버가 없습니다. + )} + {currentGroup.members?.map((user) => ( + + {user.name} + {user.email} + + + + + ))} + +
+
+
+ )} +
+ ); +} + +export default TenantGroupsPage; diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx new file mode 100644 index 00000000..ff29c847 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx @@ -0,0 +1,87 @@ +import { useQuery } from "@tanstack/react-query"; +import { Building2, Plus, ArrowRight } from "lucide-react"; +import { Link, useParams, useNavigate } from "react-router-dom"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card"; +import { Button } from "../../../components/ui/button"; +import { Badge } from "../../../components/ui/badge"; +import { fetchTenants } from "../../../lib/adminApi"; + +function TenantSubTenantsPage() { + const { tenantId } = useParams<{ tenantId: string }>(); + const navigate = useNavigate(); + + const { data, isLoading } = useQuery({ + queryKey: ["sub-tenants", tenantId], + queryFn: () => fetchTenants(50, 0, tenantId), + enabled: !!tenantId, + }); + + const subTenants = data?.items ?? []; + + return ( + + +
+ + + Sub-tenants ({subTenants.length}) + + 현재 테넌트 하위에 생성된 조직입니다. +
+ +
+ + + + + NAME + SLUG + STATUS + ACTION + + + + {subTenants.length === 0 && ( + + + 하위 테넌트가 없습니다. + + + )} + {subTenants.map((t) => ( + + {t.name} + {t.slug} + + + {t.status} + + + + + + + ))} + +
+
+
+ ); +} + +export default TenantSubTenantsPage; diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx new file mode 100644 index 00000000..5f850802 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -0,0 +1,91 @@ +import { useQuery } from "@tanstack/react-query"; +import { User, Mail, Phone, ShieldCheck } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"; +import { Badge } from "../../../components/ui/badge"; +import { fetchUsers, fetchTenant } from "../../../lib/adminApi"; + +function TenantUsersPage() { + const { tenantId } = useParams<{ tenantId: string }>(); + + // 테넌트의 슬러그(companyCode)를 먼저 가져옴 + const tenantQuery = useQuery({ + queryKey: ["tenant", tenantId], + queryFn: () => fetchTenant(tenantId!), + enabled: !!tenantId, + }); + + const companyCode = tenantQuery.data?.slug; + + // 해당 슬러그로 사용자 검색 + const usersQuery = useQuery({ + queryKey: ["users", { companyCode }], + queryFn: () => fetchUsers(100, 0, companyCode), + enabled: !!companyCode, + }); + + const users = usersQuery.data?.items ?? []; + + return ( + + + + + Tenant Members ({users.length}) + + + + + + + NAME + EMAIL + ROLE + STATUS + + + + {users.length === 0 && ( + + + 소속된 사용자가 없습니다. + + + )} + {users.map((user) => ( + + {user.name} + +
+ + {user.email} +
+
+ + + {user.role.replace("_", " ")} + + + + + {user.status} + + +
+ ))} +
+
+
+
+ ); +} + +export default TenantUsersPage; diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7c7931c5..f5326946 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 2a4ab92a..ff8bef3a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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 ) } diff --git a/backend/internal/domain/relying_party.go b/backend/internal/domain/relying_party.go index 58489666..819f1b16 100644 --- a/backend/internal/domain/relying_party.go +++ b/backend/internal/domain/relying_party.go @@ -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 diff --git a/backend/internal/domain/user_group.go b/backend/internal/domain/user_group.go new file mode 100644 index 00000000..2d88ff3b --- /dev/null +++ b/backend/internal/domain/user_group.go @@ -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 +} + diff --git a/backend/internal/handler/user_group_handler.go b/backend/internal/handler/user_group_handler.go new file mode 100644 index 00000000..89df6a47 --- /dev/null +++ b/backend/internal/handler/user_group_handler.go @@ -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) +} diff --git a/backend/internal/repository/user_group_repository.go b/backend/internal/repository/user_group_repository.go new file mode 100644 index 00000000..b44bd849 --- /dev/null +++ b/backend/internal/repository/user_group_repository.go @@ -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 +} + diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index e27d51f0..e487c3b0 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -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 { diff --git a/backend/internal/service/keto_service.go b/backend/internal/service/keto_service.go index 2ee23a86..a75bb285 100644 --- a/backend/internal/service/keto_service.go +++ b/backend/internal/service/keto_service.go @@ -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"` } diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 8a9c3499..5ac1da45 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -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:#parent_tenant@Tenant: 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:, 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 +} + diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go new file mode 100644 index 00000000..4e735726 --- /dev/null +++ b/backend/internal/service/user_group_service.go @@ -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:#parent_tenant@Tenant: + 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:#members@User: + 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 +}