@@ -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 dcd1163a..e6ac69dd 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -163,7 +163,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;
}
@@ -173,22 +180,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}`,
+ );
}
// API Key Management (M2M)
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 7ed87e7e..3160cd03 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -244,10 +244,15 @@ func main() {
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
- tenantService := service.NewTenantService(tenantRepo)
- tenantService.SetKetoService(ketoService) // Keto 주입
+ userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
- // relyingPartyRepo removed as SSOT is now Hydra+Keto
+ kratosAdminService := service.NewKratosAdminService()
+ oryAdminProvider := service.NewOryProvider()
+
+ tenantService := service.NewTenantService(tenantRepo)
+ userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
+ tenantService.SetKetoService(ketoService) // Keto 주입
+
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
@@ -255,12 +260,11 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
- adminHandler := handler.NewAdminHandler()
- devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
- tenantHandler := handler.NewTenantHandler(db, tenantService)
- relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
- kratosAdminService := service.NewKratosAdminService()
- oryAdminProvider := service.NewOryProvider()
+ adminHandler := handler.NewAdminHandler(ketoService)
+ devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
+ tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
+ userGroupHandler := handler.NewUserGroupHandler(userGroupService)
+ relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -535,6 +539,22 @@ func main() {
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
+ admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
+ 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)
+
+ // User Group Management (Tenant Admin/Super Admin)
+ 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 fcd15586..c91c9475 100644
--- a/backend/internal/bootstrap/bootstrap.go
+++ b/backend/internal/bootstrap/bootstrap.go
@@ -34,6 +34,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
+ &domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},
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/user_group_handler.go b/backend/internal/handler/user_group_handler.go
index a3bde03b..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)
}
@@ -93,3 +93,39 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
}
return c.SendStatus(fiber.StatusNoContent)
}
+
+func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
+ groupID := c.Params("id")
+ var req struct {
+ TenantID string `json:"tenantId"`
+ Relation string `json:"relation"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
+ }
+
+ if err := h.Service.AssignRoleToTenant(c.Context(), groupID, req.TenantID, req.Relation); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.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")
+ relation := c.Params("relation")
+
+ if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.SendStatus(fiber.StatusNoContent)
+}
diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go
index 920a87d4..4b8df4fd 100644
--- a/backend/internal/middleware/rbac.go
+++ b/backend/internal/middleware/rbac.go
@@ -37,20 +37,38 @@ 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)
+ }
+
// 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()
@@ -136,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/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 9303485b..f1ec3cc8 100644
--- a/backend/internal/service/keto_service.go
+++ b/backend/internal/service/keto_service.go
@@ -169,7 +169,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_service.go b/backend/internal/service/tenant_service.go
index 417d0b97..1e187cd0 100644
--- a/backend/internal/service/tenant_service.go
+++ b/backend/internal/service/tenant_service.go
@@ -39,6 +39,74 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
return s.repo.FindByID(ctx, id)
}
+func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
+ if s.keto == nil {
+ return nil, errors.New("keto service not initialized")
+ }
+
+ // 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 direct tenants", "userID", userID, "error", err)
+ }
+
+ // 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
+ // 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
+ ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
+ if err != nil {
+ slog.Error("Failed to list owned groups", "userID", userID, "error", err)
+ }
+
+ // 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 _, 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)
+ }
+ }
+ }
+
+ // 합산 및 중복 제거
+ allIDsMap := make(map[string]bool)
+ for _, id := range directTenantIDs {
+ allIDsMap[id] = true
+ }
+ for _, id := range ownedGroupIDs {
+ allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
+ }
+ for _, id := range inheritedTenantIDs {
+ allIDsMap[id] = true
+ }
+
+ allIDs := make([]string, 0, len(allIDsMap))
+ for id := range allIDsMap {
+ allIDs = append(allIDs, id)
+ }
+
+ if len(allIDs) == 0 {
+ return []domain.Tenant{}, nil
+ }
+
+ return s.repo.FindByIDs(ctx, allIDs)
+}
+
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
// Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok {
diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go
index 881c2d0f..12635d08 100644
--- a/backend/internal/service/user_group_service.go
+++ b/backend/internal/service/user_group_service.go
@@ -17,19 +17,34 @@ type UserGroupService interface {
// Member Management with Keto Sync
AddMember(ctx context.Context, groupID, userID string) error
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
}
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,
}
}
@@ -66,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 {
@@ -119,3 +173,63 @@ 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.
+ subject := "UserGroup:" + groupID + "#members"
+ err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
+ if err != nil {
+ slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
+ return err
+ }
+ return nil
+}
+
+func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
+ subject := "UserGroup:" + groupID + "#members"
+ err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
+ if err != nil {
+ slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
+ return err
+ }
+ return nil
+}
diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts
index dc7268e1..b88bfb44 100644
--- a/docker/ory/keto/namespaces.ts
+++ b/docker/ory/keto/namespaces.ts
@@ -22,8 +22,8 @@ class UserGroup implements Namespace {
class Tenant implements Namespace {
related: {
- admins: User[]
- members: User[]
+ admins: (User | SubjectSet)[]
+ members: (User | SubjectSet)[]
parent: Tenant[]
parent_group: TenantGroup[]
}
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/`