forked from baron/baron-sso
SSOT 전환
This commit is contained in:
221
adminfront/src/features/tenants/routes/TenantGroupsPage.tsx
Normal file
221
adminfront/src/features/tenants/routes/TenantGroupsPage.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
91
adminfront/src/features/tenants/routes/TenantUsersPage.tsx
Normal file
91
adminfront/src/features/tenants/routes/TenantUsersPage.tsx
Normal 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;
|
||||||
@@ -249,9 +249,9 @@ func main() {
|
|||||||
tenantService := service.NewTenantService(tenantRepo)
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
relyingPartyRepo := repository.NewRelyingPartyRepository(db)
|
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||||
hydraService := service.NewHydraAdminService()
|
hydraService := service.NewHydraAdminService()
|
||||||
relyingPartyService := service.NewRelyingPartyService(relyingPartyRepo, hydraService, ketoService)
|
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||||
secretRepo := repository.NewClientSecretRepository(db)
|
secretRepo := repository.NewClientSecretRepository(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
&domain.ApiKey{},
|
&domain.ApiKey{},
|
||||||
&domain.IdentityProviderConfig{},
|
&domain.IdentityProviderConfig{},
|
||||||
&domain.ClientSecret{},
|
&domain.ClientSecret{},
|
||||||
&domain.RelyingParty{},
|
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||||
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
|
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,18 @@ package domain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RelyingParty represents an OAuth2 Client owner by a Tenant.
|
// RelyingParty represents an OAuth2 Client owner by a Tenant.
|
||||||
// It maps 1:1 to a Hydra Client.
|
// It maps 1:1 to a Hydra Client.
|
||||||
type RelyingParty struct {
|
type RelyingParty struct {
|
||||||
ClientID string `gorm:"primaryKey" json:"clientId"` // Maps to Hydra Client ID
|
ClientID string `json:"clientId"` // Maps to Hydra Client ID
|
||||||
TenantID string `gorm:"index" json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
// DeletedAt removed as it's not a DB model anymore
|
||||||
|
|
||||||
// We don't store OAuth2 specific config here (redirect_uris, etc.)
|
|
||||||
// those are fetched from Hydra on demand.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rp *RelyingParty) TableName() string {
|
// TableName removed
|
||||||
return "relying_parties"
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
Update(ctx context.Context, user *domain.User) error
|
||||||
FindByEmail(ctx context.Context, email string) (*domain.User, error)
|
FindByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||||
FindByID(ctx context.Context, id 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)
|
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
||||||
List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, 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
|
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) {
|
func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil {
|
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)
|
CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error)
|
||||||
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
DeleteRelation(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 {
|
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 {
|
type checkResponse struct {
|
||||||
Allowed bool `json:"allowed"`
|
Allowed bool `json:"allowed"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/repository"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -18,33 +17,16 @@ type RelyingPartyService interface {
|
|||||||
Delete(ctx context.Context, clientID string) error
|
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 {
|
type relyingPartyService struct {
|
||||||
repo repository.RelyingPartyRepository
|
|
||||||
hydraService *HydraAdminService
|
hydraService *HydraAdminService
|
||||||
ketoService KetoService
|
ketoService KetoService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRelyingPartyService(
|
func NewRelyingPartyService(
|
||||||
repo repository.RelyingPartyRepository,
|
|
||||||
hydraService *HydraAdminService,
|
hydraService *HydraAdminService,
|
||||||
ketoService KetoService,
|
ketoService KetoService,
|
||||||
) RelyingPartyService {
|
) RelyingPartyService {
|
||||||
return &relyingPartyService{
|
return &relyingPartyService{
|
||||||
repo: repo,
|
|
||||||
hydraService: hydraService,
|
hydraService: hydraService,
|
||||||
ketoService: ketoService,
|
ketoService: ketoService,
|
||||||
}
|
}
|
||||||
@@ -52,104 +34,146 @@ func NewRelyingPartyService(
|
|||||||
|
|
||||||
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||||
// 1. Create Client in Hydra
|
// 1. Create Client in Hydra
|
||||||
// Ensure metadata contains tenant_id for reference
|
|
||||||
if client.Metadata == nil {
|
if client.Metadata == nil {
|
||||||
client.Metadata = make(map[string]interface{})
|
client.Metadata = make(map[string]interface{})
|
||||||
}
|
}
|
||||||
client.Metadata["tenant_id"] = tenantID
|
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)
|
createdClient, err := s.hydraService.CreateClient(ctx, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create Record in DB
|
// 2. Create Relation in Keto
|
||||||
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
|
|
||||||
// RelyingParty:<client_id>#parent_tenant@Tenant:<tenant_id>
|
// RelyingParty:<client_id>#parent_tenant@Tenant:<tenant_id>
|
||||||
err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID)
|
err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to create keto relation for relying party", "error", err, "client_id", createdClient.ClientID)
|
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.
|
// Try to cleanup Hydra client
|
||||||
// Or return error and let caller decide? For MVP, logging error is acceptable as per issue discussion (Eventual Consistency preferred).
|
_ = 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) {
|
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)
|
hydraClient, err := s.hydraService.GetClient(ctx, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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) {
|
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) {
|
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)
|
updatedClient, err := s.hydraService.UpdateClient(ctx, clientID, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return s.mapHydraToDomain(updatedClient), nil
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error {
|
func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error {
|
||||||
// Delete from DB
|
// 1. Get client to find tenantID (for Keto cleanup)
|
||||||
if err := s.repo.Delete(ctx, clientID); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from Hydra
|
// 3. Delete from Keto
|
||||||
if err := s.hydraService.DeleteClient(ctx, clientID); err != nil {
|
if tenantID != "" {
|
||||||
slog.Error("Failed to delete hydra client", "error", err, "client_id", clientID)
|
_ = s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID)
|
||||||
// Proceeding...
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
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