1
0
forked from baron/baron-sso

SSOT 전환

This commit is contained in:
2026-02-05 10:15:54 +09:00
parent c811b7e283
commit d8f133b1e5
13 changed files with 874 additions and 93 deletions

View File

@@ -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<string | null>(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 (
<div className="space-y-6 mt-6">
<div className="grid gap-6 md:grid-cols-3">
{/* 그룹 생성 폼 */}
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name"> </Label>
<Input
id="name"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="예: 개발팀, 인사팀"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desc"></Label>
<Input
id="desc"
value={newGroupDesc}
onChange={e => setNewGroupNameDesc(e.target.value)}
placeholder="그룹 용도 설명"
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
</Button>
</CardContent>
</Card>
{/* 그룹 목록 */}
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Groups</CardTitle>
<CardDescription> .</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
<RefreshCw size={14} />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>MEMBERS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.data?.map((group) => (
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
onClick={() => setSelectedGroupId(group.id)}
>
<TableCell>
<div className="font-semibold flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
{group.name}
</div>
<p className="text-[10px] text-muted-foreground">{group.description}</p>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.members?.length || 0} </Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
<UserPlus size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
[{currentGroup.name}]
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground"> .</TableCell></TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
export default TenantGroupsPage;

View File

@@ -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 (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
Sub-tenants ({subTenants.length})
</CardTitle>
<CardDescription> .</CardDescription>
</div>
<Button size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead className="text-right">ACTION</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{subTenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-semibold">{t.name}</TableCell>
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
<TableCell>
<Badge variant={t.status === "active" ? "default" : "secondary"}>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default TenantSubTenantsPage;

View File

@@ -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 (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
Tenant Members ({users.length})
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{user.role.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "muted"}>
{user.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default TenantUsersPage;

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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

View 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
}

View 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)
}

View 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
}

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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
}

View 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
}