@@ -74,6 +75,16 @@ function TenantDetailPage() {
>
Admins
+
+ User Groups
+
fetchTenants(100, 0),
+ });
+
+ if (isTenantsLoading) return
Loading tenants and groups...
;
+
+ return (
+
+
+
+
+ {tenantList?.items.map((tenant) => (
+
+ ))}
+
+
+ );
+}
+
+function TenantGroupCard({ tenant }: { tenant: any }) {
+ const { data: groups, isLoading } = useQuery({
+ queryKey: ["tenant-user-groups", tenant.id],
+ queryFn: () => fetchGroups(tenant.id),
+ });
+
+ return (
+
+
+
+
+
+ {tenant.name}
+ {tenant.slug}
+
+
+ 이 테넌트에 정의된 유저 그룹 목록입니다.
+
+
+
+
+
+ 그룹 관리
+
+
+
+
+
+
+
+ 그룹명
+ 설명
+ 멤버 수
+ 작업
+
+
+
+ {isLoading ? (
+
+ Loading...
+
+ ) : groups?.length === 0 ? (
+
+
+ 등록된 유저 그룹이 없습니다.
+
+
+ ) : (
+ groups?.map((group) => (
+
+
+
+
+
+ {group.name}
+
+
+
+ {group.description || "-"}
+ {group.members?.length || 0} 명
+
+
+ 상세보기
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
new file mode 100644
index 00000000..2f980a25
--- /dev/null
+++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
@@ -0,0 +1,205 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus, Trash2, Users } from "lucide-react";
+import { useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import { Button } from "../../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../../components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "../../../components/ui/dialog";
+import { Input } from "../../../components/ui/input";
+import { Label } from "../../../components/ui/label";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../../components/ui/table";
+import { createGroup, deleteGroup, fetchGroups } from "../../../lib/adminApi";
+
+export function TenantUserGroupsTab() {
+ const { tenantId } = useParams<{ tenantId: string }>();
+ const queryClient = useQueryClient();
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
+ const [newGroupName, setNewGroupName] = useState("");
+ const [newGroupDesc, setNewGroupDesc] = useState("");
+
+ const { data: groups, isLoading } = useQuery({
+ queryKey: ["tenant-user-groups", tenantId],
+ queryFn: () => fetchGroups(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["tenant-user-groups", tenantId],
+ });
+ setIsCreateOpen(false);
+ setNewGroupName("");
+ setNewGroupDesc("");
+ alert("User group created successfully");
+ },
+ onError: (error: any) => {
+ alert(error.message || "Failed to create user group");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (groupId: string) => deleteGroup(tenantId!, groupId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["tenant-user-groups", tenantId],
+ });
+ alert("User group deleted successfully");
+ },
+ });
+
+ if (isLoading) return
Loading user groups...
;
+
+ return (
+
+
+
+
+ User Groups
+
+ Manage user groups within this tenant for collective permission
+ assignment.
+
+
+
+
+
+
+ Create Group
+
+
+
+
+ Create User Group
+
+ Create a new group to manage users collectively.
+
+
+
+
+ setIsCreateOpen(false)}
+ >
+ Cancel
+
+ createMutation.mutate()}
+ disabled={!newGroupName || createMutation.isPending}
+ >
+ {createMutation.isPending ? "Creating..." : "Create Group"}
+
+
+
+
+
+
+
+
+
+ Name
+ Description
+ Created At
+ Actions
+
+
+
+ {groups?.length === 0 ? (
+
+
+ No user groups found for this tenant.
+
+
+ ) : (
+ groups?.map((group) => (
+
+
+
+
+
+ {group.name}
+
+
+
+ {group.description || "-"}
+
+ {group.createdAt
+ ? new Date(group.createdAt).toLocaleDateString()
+ : "-"}
+
+
+ {
+ if (
+ confirm(
+ "Are you sure you want to delete this group?",
+ )
+ ) {
+ deleteMutation.mutate(group.id);
+ }
+ }}
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx
new file mode 100644
index 00000000..311c034a
--- /dev/null
+++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx
@@ -0,0 +1,410 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, Plus, Shield, Trash2, UserPlus, Users } from "lucide-react";
+import { useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import { Badge } from "../../../components/ui/badge";
+import { Button } from "../../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../../components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "../../../components/ui/dialog";
+import { Input } from "../../../components/ui/input";
+import { Label } from "../../../components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../../components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../../components/ui/table";
+import {
+ addGroupMember,
+ assignGroupRole,
+ fetchGroup,
+ fetchGroupRoles,
+ fetchTenants,
+ fetchUsers,
+ removeGroupMember,
+ removeGroupRole,
+} from "../../../lib/adminApi";
+
+export function UserGroupDetailPage() {
+ const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
+ const queryClient = useQueryClient();
+
+ const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
+ const [selectedUserId, setSelectedUserId] = useState("");
+ const [searchUser, setSearchUser] = useState("");
+
+ const [isAddRoleOpen, setIsAddRoleOpen] = useState(false);
+ const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
+ const [selectedRelation, setSelectedRelation] = useState("view");
+
+ // Fetch specific group details
+ const { data: currentGroup, isLoading: isGroupLoading, error } = useQuery({
+ queryKey: ["user-group-detail", id],
+ queryFn: () => fetchGroup(tenantId!, id!),
+ enabled: !!id && !!tenantId,
+ retry: false,
+ });
+
+ // Fetch assigned roles
+ const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
+ queryKey: ["user-group-roles", id],
+ queryFn: () => fetchGroupRoles(tenantId!, id!),
+ enabled: !!id && !!tenantId,
+ });
+
+ // Fetch all users for selection
+ const { data: userList } = useQuery({
+ queryKey: ["admin-users", searchUser],
+ queryFn: () => fetchUsers(20, 0, searchUser),
+ enabled: isAddMemberOpen,
+ });
+
+ // Fetch all tenants for role assignment
+ const { data: tenantList } = useQuery({
+ queryKey: ["admin-tenants"],
+ queryFn: () => fetchTenants(100, 0),
+ enabled: isAddRoleOpen,
+ });
+
+ const addMemberMutation = useMutation({
+ mutationFn: (userId: string) => addGroupMember(tenantId!, id!, userId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
+ setIsAddMemberOpen(false);
+ setSelectedUserId("");
+ alert("Member added successfully");
+ },
+ onError: (error: any) => {
+ alert(error.message || "Failed to add member");
+ },
+ });
+
+ const removeMemberMutation = useMutation({
+ mutationFn: (userId: string) => removeGroupMember(tenantId!, id!, userId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
+ alert("Member removed successfully");
+ },
+ });
+
+ const assignRoleMutation = useMutation({
+ mutationFn: () =>
+ assignGroupRole(
+ tenantId!,
+ id!,
+ selectedTargetTenantId,
+ selectedRelation,
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
+ setIsAddRoleOpen(false);
+ alert(`Role '${selectedRelation}' assigned successfully`);
+ },
+ onError: (error: any) => {
+ alert(error.message || "Failed to assign role");
+ },
+ });
+
+ const removeRoleMutation = useMutation({
+ mutationFn: (role: { targetTenantId: string; relation: string }) =>
+ removeGroupRole(tenantId!, id!, role.targetTenantId, role.relation),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
+ alert("Role removed successfully");
+ },
+ });
+
+ if (isGroupLoading) return (
+
+
+
Loading group details...
+
+ );
+
+ if (error || !currentGroup) return (
+
+
Could not load group
+
+
Error: {(error as any)?.response?.data?.error || (error as any)?.message || "Not found"}
+
Path: /admin/tenants/{tenantId}/user-groups/{id}
+
+
The group ID might be invalid or you don't have sufficient permissions.
+
window.location.reload()}>Retry
+
+
+ Return to Group List
+
+
+
+ );
+
+ return (
+
+
+
+
+ {/* Members Management */}
+
+
+
+ Members
+ Manage users in this group.
+
+
+
+
+
+ Add Member
+
+
+
+
+ Add Member
+
+ Select a user to add to this group.
+
+
+
+
+ Search User
+ setSearchUser(e.target.value)}
+ />
+
+
+ Select User
+
+
+
+
+
+ {userList?.items.map((user) => (
+
+ {user.name} ({user.email})
+
+ ))}
+
+
+
+
+
+ setIsAddMemberOpen(false)}>
+ Cancel
+
+ addMemberMutation.mutate(selectedUserId)}
+ disabled={!selectedUserId || addMemberMutation.isPending}
+ >
+ Add
+
+
+
+
+
+
+
+
+
+ User
+ Action
+
+
+
+ {!currentGroup.members || currentGroup.members.length === 0 ? (
+
+
+ No members in this group.
+
+
+ ) : (
+ currentGroup.members.map((member) => (
+
+
+
+
{member.name}
+
{member.email}
+
+
+
+ removeMemberMutation.mutate(member.id)}
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {/* Roles/Permissions Management (Keto Based) */}
+
+
+
+ Permissions
+ Tenant roles assigned to this group.
+
+
+
+
+
+ Assign Role
+
+
+
+
+ Assign Tenant Role
+
+ Members of this group will inherit this role on the target tenant.
+
+
+
+
+ Target Tenant
+
+
+
+
+
+ {tenantList?.items.map((t) => (
+
+ {t.name} ({t.slug})
+
+ ))}
+
+
+
+
+ Role (Relation)
+
+
+
+
+
+ View (Read-only)
+ Manage (Read/Write)
+ Admin (Full Control)
+
+
+
+
+
+ setIsAddRoleOpen(false)}>
+ Cancel
+
+ assignRoleMutation.mutate()}
+ disabled={!selectedTargetTenantId || assignRoleMutation.isPending}
+ >
+ Assign
+
+
+
+
+
+
+
+
+
+ Target Tenant
+ Role
+ Action
+
+
+
+ {isRolesLoading ? (
+ Loading...
+ ) : !groupRoles || groupRoles.length === 0 ? (
+
+
+ No roles assigned.
+
+
+ ) : (
+ groupRoles.map((role, idx) => (
+
+
+ {role.tenantName || role.tenantId}
+
+
+ {role.relation}
+
+
+ removeRoleMutation.mutate({ targetTenantId: role.tenantId, relation: role.relation })}
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 51c7ced4..530240a0 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -186,7 +186,14 @@ export type GroupCreateRequest = {
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get
(
- `/v1/admin/tenants/${tenantId}/groups`,
+ `/v1/admin/tenants/${tenantId}/user-groups`,
+ );
+ return data;
+}
+
+export async function fetchGroup(tenantId: string, groupId: string) {
+ const { data } = await apiClient.get(
+ `/v1/admin/tenants/${tenantId}/user-groups/${groupId}`,
);
return data;
}
@@ -196,22 +203,71 @@ export async function createGroup(
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post(
- `/v1/admin/tenants/${tenantId}/groups`,
+ `/v1/admin/tenants/${tenantId}/user-groups`,
payload,
);
return data;
}
-export async function deleteGroup(groupId: string) {
- await apiClient.delete(`/v1/admin/groups/${groupId}`);
+export async function deleteGroup(tenantId: string, groupId: string) {
+ await apiClient.delete(`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`);
}
-export async function addGroupMember(groupId: string, userId: string) {
- await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
+export async function addGroupMember(
+ tenantId: string,
+ groupId: string,
+ userId: string,
+) {
+ await apiClient.post(
+ `/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members`,
+ { userId },
+ );
}
-export async function removeGroupMember(groupId: string, userId: string) {
- await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
+export async function removeGroupMember(
+ tenantId: string,
+ groupId: string,
+ userId: string,
+) {
+ await apiClient.delete(
+ `/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members/${userId}`,
+ );
+}
+
+export type GroupRole = {
+ tenantId: string;
+ tenantName: string;
+ relation: string;
+};
+
+export async function fetchGroupRoles(tenantId: string, groupId: string) {
+ const { data } = await apiClient.get(
+ `/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`,
+ );
+ return data;
+}
+
+export async function assignGroupRole(
+ tenantId: string,
+ groupId: string,
+ targetTenantId: string,
+ relation: string,
+) {
+ await apiClient.post(
+ `/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`,
+ { tenantId: targetTenantId, relation },
+ );
+}
+
+export async function removeGroupRole(
+ tenantId: string,
+ groupId: string,
+ targetTenantId: string,
+ relation: string,
+) {
+ await apiClient.delete(
+ `/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles/${targetTenantId}/${relation}`,
+ );
}
// Tenant Group Management (Global Grouping of Tenants)
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 21684322..15e3f9cc 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -245,28 +245,25 @@ func main() {
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
- tenantGroupRepo := repository.NewTenantGroupRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
+ kratosAdminService := service.NewKratosAdminService()
+ oryAdminProvider := service.NewOryProvider()
+
tenantService := service.NewTenantService(tenantRepo)
- tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
- userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, ketoService)
+ userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
- // relyingPartyRepo removed as SSOT is now Hydra+Keto
+
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
- kratosAdminService := service.NewKratosAdminService()
- oryAdminProvider := service.NewOryProvider()
-
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
- tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
@@ -576,29 +573,18 @@ func main() {
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
- // Tenant Group Management (Super Admin Only)
- admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
- admin.Post("/tenant-groups", requireSuperAdmin, tenantGroupHandler.CreateGroup)
- admin.Get("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.GetGroup)
- admin.Put("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.UpdateGroup)
- admin.Delete("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.DeleteGroup)
- admin.Post("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.AddTenantToGroup)
- admin.Delete("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.RemoveTenantFromGroup)
- admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins)
- admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin)
- admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin)
-
// User Group Management (Tenant Admin/Super Admin)
- userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"))
- userGroups.Get("/", userGroupHandler.List)
- userGroups.Post("/", userGroupHandler.Create)
- userGroups.Get("/:id", userGroupHandler.Get)
- userGroups.Put("/:id", userGroupHandler.Update)
- userGroups.Delete("/:id", userGroupHandler.Delete)
- userGroups.Post("/:id/members", userGroupHandler.AddMember)
- userGroups.Delete("/:id/members/:userId", userGroupHandler.RemoveMember)
- userGroups.Post("/:id/roles", userGroupHandler.AssignRole)
- userGroups.Delete("/:id/roles/:tenantId/:relation", userGroupHandler.RemoveRole)
+ userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
+ userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
+ userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
+ userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
+ userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
+ userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
+ userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
+ userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
+ userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
+ userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
+ userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go
index f36b841b..c91c9475 100644
--- a/backend/internal/bootstrap/bootstrap.go
+++ b/backend/internal/bootstrap/bootstrap.go
@@ -31,10 +31,10 @@ func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
// Add all domain models here
return db.AutoMigrate(
- &domain.TenantGroup{},
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
+ &domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},
diff --git a/backend/internal/bootstrap/keto_sync.go b/backend/internal/bootstrap/keto_sync.go
index 80e7badf..676451ce 100644
--- a/backend/internal/bootstrap/keto_sync.go
+++ b/backend/internal/bootstrap/keto_sync.go
@@ -25,18 +25,6 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
if t.ParentID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
}
- if t.TenantGroupID != nil {
- _ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent_group", *t.TenantGroupID)
- }
- }
-
- // 1.1 Sync Tenant Groups (Group Admins)
- var groups []domain.TenantGroup
- if err := db.Find(&groups).Error; err == nil {
- slog.Info("Syncing tenant groups to Keto", "count", len(groups))
- for range groups {
- // 그룹 관리자 개념 확정 후 관계 생성 로직 추가 예정
- }
}
// 2. Sync All Users
diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go
index 1cf9d532..2383c840 100644
--- a/backend/internal/domain/tenant.go
+++ b/backend/internal/domain/tenant.go
@@ -17,47 +17,23 @@ const (
// Tenant represents a tenant model stored in PostgreSQL.
type Tenant struct {
- ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
- ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
- TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
- TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
- Name string `gorm:"not null" json:"name"`
- Slug string `gorm:"uniqueIndex;not null" json:"slug"`
- Description string `json:"description"`
- Status string `gorm:"default:'pending'" json:"status"`
- Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
- Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
+ ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
+ Name string `gorm:"not null" json:"name"`
+ Slug string `gorm:"uniqueIndex;not null" json:"slug"`
+ Description string `json:"description"`
+ Status string `gorm:"default:'pending'" json:"status"`
+ Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
+ Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (t *Tenant) IsActive() bool {
return t.Status == TenantStatusActive
}
-// GetMergedConfig merges the group-level config with tenant-level config.
-// Tenant config takes precedence.
-func (t *Tenant) GetMergedConfig() JSONMap {
- merged := make(JSONMap)
-
- // 1. Apply Group Config (Base)
- if t.TenantGroup != nil && t.TenantGroup.Config != nil {
- for k, v := range t.TenantGroup.Config {
- merged[k] = v
- }
- }
-
- // 2. Apply Tenant Config (Overrides)
- if t.Config != nil {
- for k, v := range t.Config {
- merged[k] = v
- }
- }
-
- return merged
-}
-
// BeforeCreate hook to generate UUID if not present.
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" {
diff --git a/backend/internal/domain/tenant_group.go b/backend/internal/domain/tenant_group.go
deleted file mode 100644
index db4485a7..00000000
--- a/backend/internal/domain/tenant_group.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package domain
-
-import (
- "time"
-
- "github.com/google/uuid"
- "gorm.io/gorm"
-)
-
-// TenantGroup represents a collection of tenants.
-type TenantGroup struct {
- ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"not null" json:"name"`
- Slug string `gorm:"uniqueIndex;not null" json:"slug"`
- Description string `json:"description"`
- Tenants []Tenant `gorm:"foreignKey:TenantGroupID" json:"tenants,omitempty"`
- Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
-}
-
-func (tg *TenantGroup) TableName() string {
- return "tenant_groups"
-}
-
-func (tg *TenantGroup) BeforeCreate(tx *gorm.DB) (err error) {
- if tg.ID == "" {
- tg.ID = uuid.NewString()
- }
- return
-}
diff --git a/backend/internal/domain/user_group.go b/backend/internal/domain/user_group.go
index ecd48c78..7e644e51 100644
--- a/backend/internal/domain/user_group.go
+++ b/backend/internal/domain/user_group.go
@@ -21,6 +21,12 @@ type UserGroup struct {
Members []User `gorm:"-" json:"members,omitempty"`
}
+type GroupRole struct {
+ TenantID string `json:"tenantId"`
+ TenantName string `json:"tenantName"`
+ Relation string `json:"relation"`
+}
+
func (ug *UserGroup) TableName() string {
return "user_groups"
}
diff --git a/backend/internal/handler/tenant_group_handler.go b/backend/internal/handler/tenant_group_handler.go
deleted file mode 100644
index f394b340..00000000
--- a/backend/internal/handler/tenant_group_handler.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package handler
-
-import (
- "baron-sso-backend/internal/domain"
- "baron-sso-backend/internal/service"
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-type TenantGroupHandler struct {
- Service service.TenantGroupService
- UserService *service.KratosAdminService
-}
-
-func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
- return &TenantGroupHandler{Service: svc, UserService: userSvc}
-}
-
-type tenantGroupSummary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description"`
- Tenants []tenantSummary `json:"tenants,omitempty"`
- Config domain.JSONMap `json:"config,omitempty"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
-}
-
-func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
- limit := c.QueryInt("limit", 50)
- offset := c.QueryInt("offset", 0)
-
- groups, total, err := h.Service.ListGroups(c.Context(), limit, offset)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
-
- items := make([]tenantGroupSummary, 0, len(groups))
- for _, g := range groups {
- items = append(items, mapTenantGroupSummary(g))
- }
-
- return c.JSON(fiber.Map{
- "items": items,
- "total": total,
- "limit": limit,
- "offset": offset,
- })
-}
-
-func (h *TenantGroupHandler) GetGroup(c *fiber.Ctx) error {
- id := c.Params("id")
- group, err := h.Service.GetGroup(c.Context(), id)
- if err != nil {
- return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
- }
- return c.JSON(mapTenantGroupSummary(*group))
-}
-
-func (h *TenantGroupHandler) CreateGroup(c *fiber.Ctx) error {
- var req struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description"`
- }
- if err := c.BodyParser(&req); err != nil {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
- }
-
- group, err := h.Service.CreateGroup(c.Context(), req.Name, req.Slug, req.Description)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.Status(fiber.StatusCreated).JSON(mapTenantGroupSummary(*group))
-}
-
-func (h *TenantGroupHandler) UpdateGroup(c *fiber.Ctx) error {
- id := c.Params("id")
- var req struct {
- Name string `json:"name"`
- Description string `json:"description"`
- }
- if err := c.BodyParser(&req); err != nil {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
- }
-
- group, err := h.Service.UpdateGroup(c.Context(), id, req.Name, req.Description)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.JSON(mapTenantGroupSummary(*group))
-}
-
-func (h *TenantGroupHandler) DeleteGroup(c *fiber.Ctx) error {
- id := c.Params("id")
- if err := h.Service.DeleteGroup(c.Context(), id); err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.SendStatus(fiber.StatusNoContent)
-}
-
-func (h *TenantGroupHandler) AddTenantToGroup(c *fiber.Ctx) error {
- groupID := c.Params("id")
- tenantID := c.Params("tenantId")
-
- if err := h.Service.AddTenantToGroup(c.Context(), groupID, tenantID); err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.JSON(fiber.Map{"message": "tenant added to group"})
-}
-
-func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) error {
- groupID := c.Params("id")
- tenantID := c.Params("tenantId")
-
- if err := h.Service.RemoveTenantFromGroup(c.Context(), groupID, tenantID); err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.JSON(fiber.Map{"message": "tenant removed from group"})
-}
-
-func (h *TenantGroupHandler) ListAdmins(c *fiber.Ctx) error {
- groupID := c.Params("id")
- userIDs, err := h.Service.ListGroupAdmins(c.Context(), groupID)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
-
- type adminInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Email string `json:"email"`
- }
-
- admins := make([]adminInfo, 0, len(userIDs))
- for _, uid := range userIDs {
- identity, err := h.UserService.GetIdentity(c.Context(), uid)
- if err == nil && identity != nil {
- name, _ := identity.Traits["name"].(string)
- email, _ := identity.Traits["email"].(string)
- admins = append(admins, adminInfo{
- ID: uid,
- Name: name,
- Email: email,
- })
- } else {
- // Fallback if identity not found in Kratos
- admins = append(admins, adminInfo{ID: uid})
- }
- }
-
- return c.JSON(admins)
-}
-
-func (h *TenantGroupHandler) AddAdmin(c *fiber.Ctx) error {
- groupID := c.Params("id")
- userID := c.Params("userId")
-
- if err := h.Service.AddGroupAdmin(c.Context(), groupID, userID); err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.JSON(fiber.Map{"message": "admin added to group"})
-}
-
-func (h *TenantGroupHandler) RemoveAdmin(c *fiber.Ctx) error {
- groupID := c.Params("id")
- userID := c.Params("userId")
-
- if err := h.Service.RemoveGroupAdmin(c.Context(), groupID, userID); err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
- }
- return c.JSON(fiber.Map{"message": "admin removed from group"})
-}
-
-func mapTenantGroupSummary(g domain.TenantGroup) tenantGroupSummary {
- tenants := make([]tenantSummary, 0, len(g.Tenants))
- for _, t := range g.Tenants {
- tenants = append(tenants, mapTenantSummary(t))
- }
-
- return tenantGroupSummary{
- ID: g.ID,
- Name: g.Name,
- Slug: g.Slug,
- Description: g.Description,
- Tenants: tenants,
- Config: g.Config,
- CreatedAt: g.CreatedAt.Format(time.RFC3339),
- UpdatedAt: g.UpdatedAt.Format(time.RFC3339),
- }
-}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index 89d43663..fcf9fe1e 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -23,16 +23,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
}
type tenantSummary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description"`
- Status string `json:"status"`
- TenantGroupID *string `json:"tenantGroupId,omitempty"`
- Domains []string `json:"domains,omitempty"`
- Config domain.JSONMap `json:"config,omitempty"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Domains []string `json:"domains,omitempty"`
+ Config domain.JSONMap `json:"config,omitempty"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `string:"updatedAt"`
}
type tenantListResponse struct {
@@ -103,7 +102,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
var tenants []domain.Tenant
- if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil {
+ if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -126,7 +125,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
}
var tenant domain.Tenant
- if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil {
+ if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
}
@@ -211,7 +210,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
- TenantGroupID *string `json:"tenantGroupId"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
}
@@ -255,29 +253,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
tenant.Config = req.Config
}
- // Handle Group Change
- if req.TenantGroupID != nil {
- oldGroupID := tenant.TenantGroupID
- newGroupID := req.TenantGroupID
- if *newGroupID == "" {
- newGroupID = nil
- }
-
- // Update Keto if group changed
- if h.Keto != nil {
- // Remove old group relation if existed
- if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) {
- _ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID)
- }
- // Add new group relation
- if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) {
- _ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID)
- }
- }
-
- tenant.TenantGroupID = newGroupID
- }
-
if err := h.DB.Save(&tenant).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -387,16 +362,15 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
}
return tenantSummary{
- ID: t.ID,
- Name: t.Name,
- Slug: t.Slug,
- Description: t.Description,
- Status: t.Status,
- TenantGroupID: t.TenantGroupID,
- Domains: domains,
- Config: t.GetMergedConfig(),
- CreatedAt: t.CreatedAt.Format(time.RFC3339),
- UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
+ ID: t.ID,
+ Name: t.Name,
+ Slug: t.Slug,
+ Description: t.Description,
+ Status: t.Status,
+ Domains: domains,
+ Config: t.Config,
+ CreatedAt: t.CreatedAt.Format(time.RFC3339),
+ UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}
}
diff --git a/backend/internal/handler/user_group_handler.go b/backend/internal/handler/user_group_handler.go
index e5e3604b..359754e9 100644
--- a/backend/internal/handler/user_group_handler.go
+++ b/backend/internal/handler/user_group_handler.go
@@ -42,7 +42,7 @@ 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.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get group: " + err.Error()})
}
return c.JSON(group)
}
@@ -110,6 +110,15 @@ func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
}
+func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error {
+ groupID := c.Params("id")
+ roles, err := h.Service.ListRoles(c.Context(), groupID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(roles)
+}
+
func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
groupID := c.Params("id")
tenantID := c.Params("tenantId")
diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go
index 67afe9da..ad5c761b 100644
--- a/backend/internal/middleware/rbac.go
+++ b/backend/internal/middleware/rbac.go
@@ -37,15 +37,23 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
}
// Get object ID from path (e.g., tenant ID)
- objectID := c.Params("id")
- if objectID == "" {
+ // Fix: For Tenant namespace, prioritize tenantId param if available
+ objectID := ""
+ if namespace == "Tenant" {
objectID = c.Params("tenantId")
}
+
+ if objectID == "" {
+ objectID = c.Params("id")
+ }
if objectID == "" {
+ slog.Error("RBAC Keto check failed: missing object id", "path", c.Path())
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
}
+ slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
+
// Set tenant_id for audit logging if namespace is Tenant
if namespace == "Tenant" {
c.Locals("tenant_id", objectID)
@@ -53,9 +61,14 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
// Check with Keto
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
- if err != nil || !allowed {
+ if err != nil {
+ slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
+ }
+
+ if !allowed {
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
- return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied for " + namespace + ":" + objectID})
}
return c.Next()
@@ -141,7 +154,26 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
targetTenantID = c.Params("id") // common for /tenants/:id
}
- if profile.TenantID == nil || *profile.TenantID != targetTenantID {
+ if targetTenantID == "" {
+ return c.Next() // No target specified, let Keto or next handler decide
+ }
+
+ // Check primary tenant match
+ if profile.TenantID != nil && *profile.TenantID == targetTenantID {
+ return c.Next()
+ }
+
+ // Check inherited manageable tenants
+ isAllowed := false
+ for _, t := range profile.ManageableTenants {
+ if t.ID == targetTenantID {
+ isAllowed = true
+ break
+ }
+ }
+
+ if !isAllowed {
+ slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: you do not have access to this tenant",
})
diff --git a/backend/internal/repository/tenant_group_repository.go b/backend/internal/repository/tenant_group_repository.go
deleted file mode 100644
index 1f22e6b4..00000000
--- a/backend/internal/repository/tenant_group_repository.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package repository
-
-import (
- "baron-sso-backend/internal/domain"
- "context"
-
- "gorm.io/gorm"
-)
-
-type TenantGroupRepository interface {
- Create(ctx context.Context, group *domain.TenantGroup) error
- Update(ctx context.Context, group *domain.TenantGroup) error
- Delete(ctx context.Context, id string) error
- FindByID(ctx context.Context, id string) (*domain.TenantGroup, error)
- List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
- AddTenant(ctx context.Context, groupID, tenantID string) error
- RemoveTenant(ctx context.Context, groupID, tenantID string) error
-}
-
-type tenantGroupRepository struct {
- db *gorm.DB
-}
-
-func NewTenantGroupRepository(db *gorm.DB) TenantGroupRepository {
- return &tenantGroupRepository{db: db}
-}
-
-func (r *tenantGroupRepository) Create(ctx context.Context, group *domain.TenantGroup) error {
- return r.db.WithContext(ctx).Create(group).Error
-}
-
-func (r *tenantGroupRepository) Update(ctx context.Context, group *domain.TenantGroup) error {
- return r.db.WithContext(ctx).Save(group).Error
-}
-
-func (r *tenantGroupRepository) Delete(ctx context.Context, id string) error {
- return r.db.WithContext(ctx).Delete(&domain.TenantGroup{}, "id = ?", id).Error
-}
-
-func (r *tenantGroupRepository) FindByID(ctx context.Context, id string) (*domain.TenantGroup, error) {
- var group domain.TenantGroup
- if err := r.db.WithContext(ctx).Preload("Tenants").First(&group, "id = ?", id).Error; err != nil {
- return nil, err
- }
- return &group, nil
-}
-
-func (r *tenantGroupRepository) List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
- var groups []domain.TenantGroup
- var total int64
- db := r.db.WithContext(ctx).Model(&domain.TenantGroup{})
- db.Count(&total)
- if err := db.Limit(limit).Offset(offset).Find(&groups).Error; err != nil {
- return nil, 0, err
- }
- return groups, total, nil
-}
-
-func (r *tenantGroupRepository) AddTenant(ctx context.Context, groupID, tenantID string) error {
- return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ?", tenantID).Update("tenant_group_id", groupID).Error
-}
-
-func (r *tenantGroupRepository) RemoveTenant(ctx context.Context, groupID, tenantID string) error {
- return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ? AND tenant_group_id = ?", tenantID, groupID).Update("tenant_group_id", nil).Error
-}
diff --git a/backend/internal/repository/user_group_repository.go b/backend/internal/repository/user_group_repository.go
index 67f93fc1..c6ba02c9 100644
--- a/backend/internal/repository/user_group_repository.go
+++ b/backend/internal/repository/user_group_repository.go
@@ -37,7 +37,8 @@ func (r *userGroupRepository) Delete(ctx context.Context, id string) 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 {
+ // Using Where to be more explicit and avoid issues with GORM's default primary key handling if ID is string/uuid
+ if err := r.db.WithContext(ctx).Where("id = ?", id).First(&group).Error; err != nil {
return nil, err
}
return &group, nil
diff --git a/backend/internal/service/keto_service.go b/backend/internal/service/keto_service.go
index 2eeab758..a6fbc044 100644
--- a/backend/internal/service/keto_service.go
+++ b/backend/internal/service/keto_service.go
@@ -164,7 +164,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
}
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
- u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL))
+ u, _ := url.Parse(fmt.Sprintf("%s/admin/relation-tuples", s.writeURL))
q := u.Query()
q.Set("namespace", namespace)
q.Set("object", object)
diff --git a/backend/internal/service/tenant_group_service.go b/backend/internal/service/tenant_group_service.go
deleted file mode 100644
index bafd32bc..00000000
--- a/backend/internal/service/tenant_group_service.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package service
-
-import (
- "baron-sso-backend/internal/domain"
- "baron-sso-backend/internal/repository"
- "context"
- "log/slog"
-)
-
-type TenantGroupService interface {
- CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error)
- GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error)
- ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
- UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error)
- DeleteGroup(ctx context.Context, id string) error
- AddTenantToGroup(ctx context.Context, groupID, tenantID string) error
- RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error
- AddGroupAdmin(ctx context.Context, groupID, userID string) error
- RemoveGroupAdmin(ctx context.Context, groupID, userID string) error
- ListGroupAdmins(ctx context.Context, groupID string) ([]string, error)
-}
-
-type tenantGroupService struct {
- repo repository.TenantGroupRepository
- keto KetoService
-}
-
-func NewTenantGroupService(repo repository.TenantGroupRepository, keto KetoService) TenantGroupService {
- return &tenantGroupService{repo: repo, keto: keto}
-}
-
-func (s *tenantGroupService) CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error) {
- group := &domain.TenantGroup{
- Name: name,
- Slug: slug,
- Description: description,
- }
- if err := s.repo.Create(ctx, group); err != nil {
- return nil, err
- }
- return group, nil
-}
-
-func (s *tenantGroupService) GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error) {
- return s.repo.FindByID(ctx, id)
-}
-
-func (s *tenantGroupService) ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
- return s.repo.List(ctx, limit, offset)
-}
-
-func (s *tenantGroupService) UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error) {
- group, err := s.repo.FindByID(ctx, id)
- if err != nil {
- return nil, err
- }
- group.Name = name
- group.Description = description
- if err := s.repo.Update(ctx, group); err != nil {
- return nil, err
- }
- return group, nil
-}
-
-func (s *tenantGroupService) DeleteGroup(ctx context.Context, id string) error {
- return s.repo.Delete(ctx, id)
-}
-
-func (s *tenantGroupService) AddTenantToGroup(ctx context.Context, groupID, tenantID string) error {
- if err := s.repo.AddTenant(ctx, groupID, tenantID); err != nil {
- return err
- }
-
- // [Keto] ReBAC: Tenant -> Group membership
- if s.keto != nil {
- err := s.keto.CreateRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
- if err != nil {
- slog.Error("Failed to sync Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
- }
- }
- return nil
-}
-
-func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error {
- if err := s.repo.RemoveTenant(ctx, groupID, tenantID); err != nil {
- return err
- }
-
- // [Keto] ReBAC: Remove Tenant -> Group membership
- if s.keto != nil {
- err := s.keto.DeleteRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
- if err != nil {
- slog.Error("Failed to remove Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
- }
- }
- return nil
-}
-
-func (s *tenantGroupService) AddGroupAdmin(ctx context.Context, groupID, userID string) error {
- if s.keto == nil {
- return nil
- }
- return s.keto.CreateRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
-}
-
-func (s *tenantGroupService) RemoveGroupAdmin(ctx context.Context, groupID, userID string) error {
- if s.keto == nil {
- return nil
- }
- return s.keto.DeleteRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
-}
-
-func (s *tenantGroupService) ListGroupAdmins(ctx context.Context, groupID string) ([]string, error) {
- if s.keto == nil {
- return []string{}, nil
- }
- tuples, err := s.keto.ListRelations(ctx, "TenantGroup", groupID, "admins", "")
- if err != nil {
- return nil, err
- }
-
- userIDs := make([]string, 0, len(tuples))
- for _, t := range tuples {
- // subject_id is "User:uuid"
- if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
- userIDs = append(userIDs, t.SubjectID[5:])
- }
- }
- return userIDs, nil
-}
diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go
index e4ea83dd..5a275479 100644
--- a/backend/internal/service/tenant_service.go
+++ b/backend/internal/service/tenant_service.go
@@ -48,40 +48,54 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return nil, errors.New("keto service not initialized")
}
- // 1. Get directly managed tenants
- directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID)
+ // 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
+ directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
if err != nil {
- slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err)
+ slog.Error("Failed to list direct tenants", "userID", userID, "error", err)
}
- // 2. Get managed tenant groups
- groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID)
+ // 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
+ // 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
+ ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
if err != nil {
- slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err)
+ slog.Error("Failed to list owned groups", "userID", userID, "error", err)
}
- // 3. Get tenants belonging to those groups
- var groupInheritedTenantIDs []string
- for _, groupID := range groupIDs {
- // In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_
- // To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_
- // Wait, my ListObjects lists objects given a subject.
- // So subject="TenantGroup:"+groupID+"#_"
- // Object is Tenant ID.
- ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID)
+ // 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
+ memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
+ if err != nil {
+ slog.Error("Failed to list group memberships", "userID", userID, "error", err)
+ }
+
+ // 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
+ var inheritedTenantIDs []string
+ allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
+ for _, groupID := range allMyGroups {
+ // 해당 그룹에 부여된 테넌트 관리 권한 역추적
+ relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
if err == nil {
- for _, t := range ts {
- groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object)
+ for _, r := range relations {
+ inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
+ }
+ }
+ // view 권한도 관리 가능 목록에 포함 (필요 시)
+ relationsView, err := s.keto.ListRelations(ctx, "Tenant", "", "view", "UserGroup:"+groupID+"#members")
+ if err == nil {
+ for _, r := range relationsView {
+ inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
}
- // Combine and deduplicate IDs
+ // 합산 및 중복 제거
allIDsMap := make(map[string]bool)
for _, id := range directTenantIDs {
allIDsMap[id] = true
}
- for _, id := range groupInheritedTenantIDs {
+ for _, id := range ownedGroupIDs {
+ allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
+ }
+ for _, id := range inheritedTenantIDs {
allIDsMap[id] = true
}
diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go
index c6d8c836..12635d08 100644
--- a/backend/internal/service/user_group_service.go
+++ b/backend/internal/service/user_group_service.go
@@ -19,6 +19,7 @@ type UserGroupService interface {
RemoveMember(ctx context.Context, groupID, userID string) error
// Permission Management
+ ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error)
AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error
RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error
}
@@ -26,14 +27,24 @@ type UserGroupService interface {
type userGroupService struct {
repo repository.UserGroupRepository
userRepo repository.UserRepository
+ tenantRepo repository.TenantRepository
ketoService KetoService
+ kratos *KratosAdminService
}
-func NewUserGroupService(repo repository.UserGroupRepository, userRepo repository.UserRepository, keto KetoService) UserGroupService {
+func NewUserGroupService(
+ repo repository.UserGroupRepository,
+ userRepo repository.UserRepository,
+ tenantRepo repository.TenantRepository,
+ keto KetoService,
+ kratos *KratosAdminService,
+) UserGroupService {
return &userGroupService{
repo: repo,
userRepo: userRepo,
+ tenantRepo: tenantRepo,
ketoService: keto,
+ kratos: kratos,
}
}
@@ -70,36 +81,75 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
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:])
+ sid := t.SubjectID
+ if len(sid) > 5 && sid[:5] == "User:" {
+ userIDs = append(userIDs, sid[5:])
+ } else {
+ userIDs = append(userIDs, sid)
}
}
if len(userIDs) > 0 {
+ // 1. Try to find in local DB
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
+
+ // 2. Map existing DB members
+ memberMap := make(map[string]domain.User)
+ for _, m := range members {
+ memberMap[m.ID] = m
+ }
+
+ // 3. For IDs not in DB, fetch from Kratos
+ var finalMembers []domain.User
+ for _, uid := range userIDs {
+ if m, ok := memberMap[uid]; ok {
+ finalMembers = append(finalMembers, m)
+ } else if s.kratos != nil {
+ // Fallback to Kratos
+ identity, err := s.kratos.GetIdentity(ctx, uid)
+ if err == nil && identity != nil {
+ name, _ := identity.Traits["name"].(string)
+ email, _ := identity.Traits["email"].(string)
+ finalMembers = append(finalMembers, domain.User{
+ ID: uid,
+ Name: name,
+ Email: email,
+ })
+ }
+ }
+ }
+ group.Members = finalMembers
+ } else {
+ group.Members = []domain.User{}
}
return group, nil
}
func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
- return s.repo.ListByTenantID(ctx, tenantID)
+ groups, err := s.repo.ListByTenantID(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+
+ // For each group, fetch member count from Keto
+ for i := range groups {
+ tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", groups[i].ID, "members", "")
+ if err == nil {
+ // Create dummy members just to carry the count for the JSON response
+ groups[i].Members = make([]domain.User, len(tuples))
+ }
+ }
+
+ return groups, nil
}
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
@@ -124,6 +174,44 @@ func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID str
return nil
}
+func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
+ // Query: namespace=Tenant, subject=UserGroup:groupID#members
+ subject := "UserGroup:" + groupID + "#members"
+ tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
+ if err != nil {
+ slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
+ return nil, err
+ }
+
+ var roles []domain.GroupRole
+ tenantIDs := make([]string, 0, len(tuples))
+ for _, t := range tuples {
+ tenantIDs = append(tenantIDs, t.Object)
+ }
+
+ if len(tenantIDs) > 0 {
+ tenantList, err := s.tenantRepo.FindByIDs(ctx, tenantIDs)
+ if err != nil {
+ slog.Error("Failed to fetch tenant details for roles", "error", err)
+ }
+
+ tenantMap := make(map[string]string)
+ for _, t := range tenantList {
+ tenantMap[t.ID] = t.Name
+ }
+
+ for _, t := range tuples {
+ roles = append(roles, domain.GroupRole{
+ TenantID: t.Object,
+ TenantName: tenantMap[t.Object],
+ Relation: t.Relation,
+ })
+ }
+ }
+
+ return roles, nil
+}
+
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
// Keto: Tenant:#@UserGroup:#members
// This means all members of the group have the relation on the tenant.
diff --git a/docs/keto-rebac-policy-guide.md b/docs/keto-rebac-policy-guide.md
new file mode 100644
index 00000000..5a3556ce
--- /dev/null
+++ b/docs/keto-rebac-policy-guide.md
@@ -0,0 +1,143 @@
+# Ory Keto ReBAC 정책 및 관계 튜플 가이드
+
+이 문서는 Baron SSO의 통합 권한 정책을 Ory Keto(Zanzibar 스타일 권한 엔진)에서 구현하기 위한 네임스페이스 설계와 관계 튜플(Relationship Tuples) 예제를 정의합니다.
+
+## 0. 권한 흐름 다이어그램 (Permission Flow)
+
+```mermaid
+graph LR
+ %% Subjects
+ U[User: 사용자]
+
+ %% Intermediate Groups
+ subgraph Groups [유저 그룹 / 테넌트 관리 주체]
+ UG_O[UserGroup: Owners / 그룹장]
+ UG_M[UserGroup: Members / 멤버]
+ end
+
+ %% Resources
+ subgraph Resources [테넌트 및 하위 자원]
+ T[Tenant: 부모 테넌트]
+ CT[Tenant: 자식 테넌트]
+ RP[RelyingParty: 앱/클라이언트]
+ end
+
+ %% Relations (Solid = Direct, Dash = Inherited)
+ U -- "owner of" --> UG_O
+ U -- "member of" --> UG_M
+
+ UG_O -- "becomes Admin of" --> T
+ UG_M -- "gets View/Manage of" --> T
+
+ T -- "controls" --> CT
+ T -- "owns" --> RP
+
+ %% Effective Permissions (Dash)
+ U -. "inherits Admin" .-> T
+ U -. "inherits Access" .-> CT
+ U -. "can manage" .-> RP
+
+ %% Styles
+ style UG_O fill:#ff9,stroke:#333
+ style T fill:#dfd,stroke:#333
+ style RP fill:#bbf,stroke:#333
+```
+
+## 1. 네임스페이스 정의 (Namespaces)
+
+| 네임스페이스 | 역할 | 비고 |
+| :--- | :--- | :--- |
+| `Tenant` | 격리된 자원 공간 (Workspace) | 모든 유저 그룹은 테넌트의 한 종류임 |
+| `UserGroup` | 사용자의 집합 (Specialized Tenant) | `Tenant` 네임스페이스를 상속하거나 호환됨 |
+| `RelyingParty` | OAuth2 클라이언트 앱 | 특정 테넌트에 소속됨 |
+| `System` | 시스템 전역 권한 | Super Admin 등을 관리 |
+
+## 2. 핵심 정책의 Keto 구현
+
+### 2.1 "모든 유저 그룹은 테넌트이다"
+유저 그룹이 생성될 때, 해당 ID는 `Tenant` 네임스페이스에도 동시에 존재하며 동일한 상속 로직을 공유합니다.
+
+### 2.2 "그룹장은 해당 테넌트의 어드민이다"
+그룹장(Leader/Owner) 관계가 형성되면, Keto의 **Subject Set** 기능을 통해 테넌트의 `admins` 권한으로 자동 전파됩니다.
+
+---
+
+## 3. Keto 관계 튜플 예제 (Relationship Tuples)
+
+Keto에 저장되는 데이터 포맷 예시입니다: `namespace:object#relation@subject`
+
+### 3.1 사용자-그룹 관계 (Identity to Group)
+| 설명 | Keto 튜플 예제 |
+| :--- | :--- |
+| **그룹 멤버 추가** | `UserGroup:dev-team#members@User:uuid-123` |
+| **그룹장 임명 (Leader)** | `UserGroup:dev-team#owners@User:uuid-leader` |
+
+### 3.2 그룹-테넌트 권한 전파 (Group to Resource)
+| 설명 | Keto 튜플 예제 |
+| :--- | :--- |
+| **그룹장 -> 어드민 자동 상속** | `Tenant:dev-team#admins@UserGroup:dev-team#owners` |
+| **그룹 -> 하위 테넌트 관리 권한** | `Tenant:project-alpha#manage@UserGroup:dev-team#members` |
+| **그룹 -> 하위 테넌트 조회 권한** | `Tenant:project-beta#view@UserGroup:dev-team#members` |
+
+### 3.3 테넌트-자원 소속 관계 (Hierarchy)
+| 설명 | Keto 튜플 예제 |
+| :--- | :--- |
+| **자식 테넌트 설정** | `Tenant:child-dept#parents@Tenant:parent-corp` |
+| **앱(RP) 소속 테넌트 지정** | `RelyingParty:auth-app#parents@Tenant:hanmac-family` |
+
+---
+
+## 4. 권한 검증 로직 (Permission Check Logic)
+
+시스템이 권한을 확인할 때(Check API) 사용하는 로직입니다.
+
+### 4.1 그룹장의 테넌트 관리 권한 확인
+사용자 `uuid-leader`가 `dev-team` 테넌트를 관리할 수 있는지 확인:
+```bash
+# 요청 (Check)
+GET /relation-tuples/check?namespace=Tenant&object=dev-team&relation=manage&subject_id=uuid-leader
+
+# Keto 내부 추론 경로
+1. Tenant:dev-team#manage 권한은 Tenant:dev-team#admins에게 있음.
+2. Tenant:dev-team#admins는 UserGroup:dev-team#owners를 포함함.
+3. UserGroup:dev-team#owners에 User:uuid-leader가 존재함.
+=> 결과: ALLOW (True)
+```
+
+### 4.2 그룹 멤버의 하위 자원(RP) 접근 확인
+사용자 `uuid-123`이 `auth-app` 설정을 볼 수 있는지 확인:
+```bash
+# 요청 (Check)
+GET /relation-tuples/check?namespace=RelyingParty&object=auth-app&relation=view&subject_id=uuid-123
+
+# Keto 내부 추론 경로
+1. RelyingParty:auth-app#view 권한은 부모인 Tenant:hanmac-family#view에 의존함.
+2. Tenant:hanmac-family#view 권한은 UserGroup:dev-team#members에게 부여됨.
+3. UserGroup:dev-team#members에 User:uuid-123이 존재함.
+=> 결과: ALLOW (True)
+```
+
+## 5. 정책 요약 코드 (Namespace Config - DSL Style)
+
+이 정책을 지원하기 위한 Keto 네임스페이스 설정 스키마 개념도입니다.
+
+```typescript
+class Tenant implements Namespace {
+ related: {
+ admins: (User | UserGroup#owners)[]
+ members: (User | UserGroup#members)[]
+ parents: Tenant[]
+ }
+
+ permits: {
+ view: (ctx: Context): boolean =>
+ this.related.members.includes(ctx.subject) ||
+ this.related.admins.includes(ctx.subject) ||
+ this.related.parents.traverse((p) => p.permits.view(ctx)),
+
+ manage: (ctx: Context): boolean =>
+ this.related.admins.includes(ctx.subject) ||
+ this.related.parents.traverse((p) => p.permits.manage(ctx))
+ }
+}
+```
diff --git a/docs/rbac-rebac-policy.md b/docs/rbac-rebac-policy.md
index 4079d9e2..edeb0333 100644
--- a/docs/rbac-rebac-policy.md
+++ b/docs/rbac-rebac-policy.md
@@ -18,9 +18,9 @@
- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다.
### 2.3 RequireTenantMatch
-- 테넌트 관리자 권한을 가진 사용자가 **자신의 테넌트**에만 접근하도록 보장합니다.
-- Super Admin은 즉시 통과합니다.
-- API Key 인증은 우회합니다.
+- 사용자가 요청한 테넌트에 대한 관리 자격이 있는지 검증합니다.
+- **상속 권한 인정:** 사용자의 기본 테넌트뿐만 아니라, 유저 그룹 멤버십이나 그룹장 직책을 통해 **상속받은 모든 테넌트**를 대상으로 합니다.
+- Super Admin 및 유효한 API Key 요청은 통과합니다.
## 3. ReBAC 기반인데도 RBAC가 필요한 이유
@@ -32,7 +32,7 @@
- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다.
3) **테넌트 범위 제어의 명확성**
-- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다.
+- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다. 유저 그룹 도입 이후에는 "상속받은 모든 관리 대상 테넌트"로 범위가 확장됩니다.
4) **성능 및 안정성**
- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다.
@@ -48,16 +48,19 @@
### 4.2 권한/정책 SoT
- **1순위: Keto(ReBAC) 관계 튜플**
- - 리소스 접근 권한의 최종 판단 기준
+ - 리소스 접근 권한의 최종 판단 기준.
+ - **유저 그룹 상속:** 사용자가 속한 유저 그룹에 부여된 권한은 Keto를 통해 실시간으로 상속됩니다.
+ - **그룹장-어드민 연동:** 유저 그룹의 장(Leader)은 해당 그룹(테넌트)의 어드민 권한을 자동으로 가집니다.
- **2순위: RBAC(Role)**
- - 전역/상위 정책의 단축 규칙
- - ReBAC와 충돌 시, ReBAC 결과가 항상 우선
+ - 전역/상위 정책의 단축 규칙.
+ - ReBAC와 충돌 시, ReBAC 결과가 항상 우선.
### 4.3 테넌트 컨텍스트 SoT
-- **1순위: 서버 측 프로필(예: UserProfile.tenantId)**
+- **1순위: 서버 측 프로필 및 상속된 권한 (ManageableTenants)**
+ - 사용자의 기본 `tenantId`뿐만 아니라, 유저 그룹을 통해 **상속받은 관리 가능 테넌트 목록** 전체를 기준으로 판단합니다.
- **2순위: 요청 헤더(X-Tenant-ID)**
- - 헤더는 "요청 의도"를 나타내지만, 항상 서버 프로필과 일치해야 함
- - 불일치 시 차단
+ - 헤더는 "요청 의도"를 나타내며, `ManageableTenants` 목록에 포함된 ID여야 합니다.
+ - 불일치 시 차단.
### 4.4 OIDC/RP 정보 SoT
- **1순위: Hydra Client/Consent 데이터**
diff --git a/docs/tenant-usergroup-policy.md b/docs/tenant-usergroup-policy.md
new file mode 100644
index 00000000..fb31b518
--- /dev/null
+++ b/docs/tenant-usergroup-policy.md
@@ -0,0 +1,68 @@
+# 유저 그룹 및 테넌트 통합 권한 정책 (Integrated Policy)
+
+이 문서는 Baron SSO의 테넌트(Tenant)와 유저 그룹(User Group) 간의 관계 및 권한 상속에 관한 공식 정책을 정의합니다.
+
+## 1. 기본 원칙 (Core Axioms)
+
+### 1.1 유저 그룹의 테넌트성 (User Group as a Tenant)
+- **모든 테넌트가 유저 그룹은 아니지만, 모든 유저 그룹은 반드시 테넌트의 속성을 가집니다.**
+- 유저 그룹은 "사용자들의 집합"인 동시에, 그 자체가 권한을 담고 다른 자원을 소유할 수 있는 **격리된 공간(Tenant)**으로 취급됩니다.
+
+### 1.2 권한 상속 로직의 단일화 (Unified Inheritance)
+- 테넌트 간의 상속(Parent-Child Tenant)과 유저 그룹의 권한 전파(Group-Member)는 **기술적으로 동일한 ReBAC 로직**을 사용합니다.
+- `UserGroup:members` 관계는 `Tenant:members`와 동일한 우선순위를 가지며, 시스템은 이를 구분 없이 하나의 상속 트리로 처리합니다.
+
+### 1.3 그룹장-어드민 연동 (Leader-Admin Mapping)
+- 특정 유저 그룹에 명시적으로 **'그룹장(Group Leader)'**을 지정하면, 시스템은 해당 사용자를 해당 유저 그룹(테넌트)의 **'테넌트 어드민(Tenant Admin)'**으로 자동 격상합니다.
+- 그룹장은 해당 그룹이 소유한 모든 하위 테넌트 및 리소스에 대해 완전한 제어권을 가집니다.
+
+## 2. 권한 흐름도 (Mermaid)
+
+```mermaid
+graph TD
+ %% Roles
+ Leader[Group Leader / 그룹장]
+ Member[Group Member / 멤버]
+
+ %% Entities (Polymorphic)
+ subgraph UG_T [User Group / Specialized Tenant]
+ UG_ID[Group: Hanmac 운영팀]
+ end
+
+ subgraph Child_T [Child Tenants / 하위 테넌트]
+ T1[Tenant: 한맥 엔지니어링]
+ T2[Tenant: 한맥 IT]
+ end
+
+ %% Policy Links
+ Leader -- "Explicitly Assigned" --> UG_ID
+ Leader -. "Automatically Becomes" .-> Admin[Tenant Admin]
+
+ Member -- "is member of" --> UG_ID
+
+ %% Inheritance (Identical Logic)
+ UG_ID -- "Inherits Access To" --> T1
+ UG_ID -- "Inherits Access To" --> T2
+
+ %% Effective Access
+ Admin -- "Full Control" --> UG_ID
+ Member -- "Shared Access" --> T1
+ Member -- "Shared Access" --> T2
+
+ %% Styles
+ style UG_ID fill:#f9f,stroke:#333,stroke-width:2px
+ style Leader fill:#ff9,stroke:#333
+ style Admin fill:#ffd,stroke:#333,stroke-dasharray: 5 5
+```
+
+## 3. 기술적 구현 가이드 (Implementation)
+
+### 3.1 Keto Relationship Tuples
+- **그룹장 임명:** `UserGroup:#owners@User:`
+- **어드민 자동 승격:** `Tenant:#admins@UserGroup:#owners` (그룹 소유자는 해당 테넌트의 어드민)
+- **멤버십:** `UserGroup:#members@User:`
+
+### 3.2 기대 효과
+- **정책 단순화:** '어드민'과 '그룹장'을 별도로 관리할 필요가 없어 시스템 복잡도가 감소합니다.
+- **책임 명확화:** 그룹의 장이 해당 자원의 최종 책임자가 되는 직관적인 거버넌스를 수립합니다.
+- **일관된 UX:** 사용자는 자신이 관리하는 것이 '테넌트'인지 '그룹'인지 고민할 필요 없이 동일한 관리 도구를 사용합니다.
diff --git a/docs/user-group-rebac-architecture.md b/docs/user-group-rebac-architecture.md
new file mode 100644
index 00000000..49f978ec
--- /dev/null
+++ b/docs/user-group-rebac-architecture.md
@@ -0,0 +1,83 @@
+# 유저 그룹 기반 ReBAC 권한 아키텍처 (User Group-based ReBAC)
+
+이 문서는 Baron SSO의 이슈 #239를 통해 구현된 유저 그룹 중심의 권한 체계와 Ory Keto를 이용한 ReBAC(Relationship-Based Access Control) 설계 방식을 설명합니다.
+
+## 1. 개요
+기존의 '테넌트 그룹(Tenant Group)' 방식에서 '유저 그룹(User Group)' 방식으로 전환하여, 권한 부여의 주체(Subject)를 그룹화하고 자원(Tenant)에 대한 권한을 상속받는 구조로 설계되었습니다.
+
+## 2. 권한 상속 다이어그램
+
+```mermaid
+graph TD
+ %% Entities
+ subgraph Identity [사용자 계정]
+ U1[User: A]
+ U2[User: B]
+ end
+
+ subgraph Subjects [권한 부여 주체]
+ UG[User Group: 개발팀]
+ end
+
+ subgraph Resources [보호 대상 자원]
+ T1[Tenant: Project Alpha]
+ T2[Tenant: Project Beta]
+ RP[Relying Party: Auth App]
+ end
+
+ %% Relationships
+ U1 -- "is member of" --> UG
+ U2 -- "is member of" --> UG
+
+ UG -- "assigned role: manage" --> T1
+ UG -- "assigned role: view" --> T2
+
+ %% Inheritance Logic (Keto ReBAC)
+ T1 -- "owns" --> RP
+
+ %% Direct Inheritance
+ U1 -. "inherits: manage" .-> T1
+ U1 -. "inherits: view" .-> T2
+ U2 -. "inherits: manage" .-> T1
+
+ %% Recursive Permission
+ T1 -. "allows access" .-> RP
+ U1 -. "can manage" .-> RP
+
+ %% Styles
+ style Identity fill:#f9f,stroke:#333,stroke-width:2px
+ style Subjects fill:#bbf,stroke:#333,stroke-width:2px
+ style Resources fill:#dfd,stroke:#333,stroke-width:2px
+ linkStyle 4,5,6,7,8,9 stroke:#ff944d,stroke-width:2px,stroke-dasharray: 5 5
+```
+
+## 3. 기술적 관계 설계 (Ory Keto Tuples)
+
+Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples)을 통해 권한을 관리합니다.
+
+### 3.1 그룹 멤버십 (Group Membership)
+사용자를 특정 유저 그룹의 멤버로 등록합니다.
+- **Tuple:** `UserGroup:#members@User:`
+- **의미:** `UserID` 사용자는 `GroupID` 유저 그룹의 멤버이다.
+
+### 3.2 테넌트 권한 할당 (Tenant Role Assignment)
+유저 그룹 전체에 특정 테넌트에 대한 역할을 부여합니다.
+- **Tuple:** `Tenant:#@UserGroup:#members`
+- **의미:** `GroupID` 유저 그룹의 모든 멤버는 `TenantID` 테넌트에 대해 ``(예: `view`, `manage`, `admins`) 권한을 가진다.
+
+### 3.3 자원 소유 및 전파 (Resource Ownership)
+테넌트가 소유한 하위 자원(RP, API Key 등)에 대한 권한 전파 규칙입니다.
+- **Tuple:** `RelyingParty:#parents@Tenant:`
+- **검증 논리:** 사용자가 `ClientID`에 대한 `view` 권한을 요청하면, Keto는 해당 사용자가 부모인 `TenantID`에 대해 `view` 권한이 있는지 역추적하여 승인합니다.
+
+## 4. 주요 장점
+
+1. **중앙 집중식 관리:** 사용자의 부서 이동이나 퇴사 시, 개별 테넌트의 권한을 수정할 필요 없이 유저 그룹의 멤버십만 변경하면 모든 연관 권한이 즉시 회수/부여됩니다.
+2. **복합 권한 구성:** 하나의 그룹이 여러 테넌트에 대해 서로 다른 수준의 권한을 가질 수 있어, 실제 조직 구조와 프로젝트 협업 모델을 유연하게 반영할 수 있습니다.
+3. **Zanzibar 스타일 확장성:** Google Zanzibar 논리를 따르는 Ory Keto를 활용함으로써, 향후 수만 명의 사용자와 수천 개의 테넌트 환경에서도 성능 저하 없이 정교한 권한 체크가 가능합니다.
+
+## 5. 관련 구현 파일
+- **Backend Service:** `backend/internal/service/user_group_service.go`
+- **Backend Handler:** `backend/internal/handler/user_group_handler.go`
+- **Frontend API:** `adminfront/src/lib/adminApi.ts`
+- **Frontend UI:** `adminfront/src/features/user-groups/`