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