diff --git a/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx
new file mode 100644
index 00000000..f51f2504
--- /dev/null
+++ b/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx
@@ -0,0 +1,198 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
+import { useState } from "react";
+import { useParams } from "react-router-dom";
+import { Button } from "../../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../../components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../../components/ui/table";
+import { Input } from "../../../components/ui/input";
+import {
+ fetchTenantAdmins,
+ addTenantAdmin,
+ removeTenantAdmin,
+ fetchUsers
+} from "../../../lib/adminApi";
+
+function TenantAdminsTab() {
+ const { tenantId } = useParams<{ tenantId: string }>();
+ const queryClient = useQueryClient();
+ const [searchTerm, setSearchTerm] = useState("");
+
+ if (!tenantId) return null;
+
+ // 현재 관리자 목록
+ const adminsQuery = useQuery({
+ queryKey: ["tenant-admins", tenantId],
+ queryFn: () => fetchTenantAdmins(tenantId),
+ enabled: !!tenantId,
+ });
+
+ // 전체 사용자 목록 (관리자 추가용)
+ const usersQuery = useQuery({
+ queryKey: ["users", { limit: 100, search: searchTerm }],
+ queryFn: () => fetchUsers(100, 0, searchTerm),
+ enabled: searchTerm.length > 1,
+ });
+
+ const addMutation = useMutation({
+ mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
+ onSuccess: () => {
+ adminsQuery.refetch();
+ setSearchTerm("");
+ },
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
+ onSuccess: () => {
+ adminsQuery.refetch();
+ },
+ });
+
+ const handleAddAdmin = (userId: string) => {
+ addMutation.mutate(userId);
+ };
+
+ const handleRemoveAdmin = (userId: string, userName: string) => {
+ if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
+ removeMutation.mutate(userId);
+ }
+ };
+
+ return (
+
+ {/* 현재 테넌트 관리자 */}
+
+
+
+
+ 테넌트 관리자
+
+
+ 이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다.
+
+
+
+
+
+
+ 이름
+ 이메일
+ 회수
+
+
+
+ {adminsQuery.data?.length === 0 && (
+
+
+ 등록된 관리자가 없습니다.
+
+
+ )}
+ {adminsQuery.data?.map((admin) => (
+
+ {admin.name || "Unknown"}
+ {admin.email}
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* 사용자 검색 및 추가 */}
+
+
+
+
+
+ 관리자 추가
+
+
+
+ 관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일).
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+ 사용자
+ 추가
+
+
+
+ {searchTerm.length < 2 && (
+
+
+ 사용자 이름을 입력하여 검색하세요.
+
+
+ )}
+ {searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
+
+
+ 검색 결과가 없습니다.
+
+
+ )}
+ {usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default TenantAdminsTab;
diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx
index eb087b00..d8362077 100644
--- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx
@@ -16,6 +16,7 @@ function TenantDetailPage() {
});
const isFederationTab = location.pathname.includes("/federation");
+ const isAdminTab = location.pathname.includes("/admins");
return (
@@ -44,7 +45,7 @@ function TenantDetailPage() {
Federation
+
+ Admins
+
(
+ `/v1/admin/tenants/${tenantId}/admins`,
+ );
+ return data;
+}
+
+export async function addTenantAdmin(tenantId: string, userId: string) {
+ await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
+}
+
+export async function removeTenantAdmin(tenantId: string, userId: string) {
+ await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
+}
+
+export type GroupAdmin = {
+ id: string;
+ name: string;
+ email: string;
+};
+
+export async function fetchGroupAdmins(groupId: string) {
+ const { data } = await apiClient.get(
+ `/v1/admin/tenant-groups/${groupId}/admins`,
+ );
+ return data;
+}
+
+export async function addGroupAdmin(groupId: string, userId: string) {
+ await apiClient.post(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
+}
+
+export async function removeGroupAdmin(groupId: string, userId: string) {
+ await apiClient.delete(
+ `/v1/admin/tenant-groups/${groupId}/admins/${userId}`,
+ );
+}
+
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
@@ -465,5 +509,55 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
}
export async function deleteRelyingParty(id: string) {
+
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
-}
\ No newline at end of file
+
+}
+
+
+
+export type RPOwner = {
+
+ subject: string;
+
+ name?: string;
+
+ email?: string;
+
+ type: string;
+
+};
+
+
+
+export async function fetchRPOwners(clientId: string) {
+
+ const { data } = await apiClient.get(
+
+ `/v1/admin/relying-parties/${clientId}/owners`,
+
+ );
+
+ return data;
+
+}
+
+
+
+export async function addRPOwner(clientId: string, subject: string) {
+
+ await apiClient.post(`/v1/admin/relying-parties/${clientId}/owners/${subject}`);
+
+}
+
+
+
+export async function removeRPOwner(clientId: string, subject: string) {
+
+ await apiClient.delete(
+
+ `/v1/admin/relying-parties/${clientId}/owners/${subject}`,
+
+ );
+
+}
diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml
index 5775996e..9667573f 100644
--- a/adminfront/src/locales/en.toml
+++ b/adminfront/src/locales/en.toml
@@ -1308,3 +1308,4 @@ verify = "Verify"
action = "Action"
msg.admin.logout_confirm = "Are you sure you want to log out?"
ui.admin.nav.logout = "Logout"
+ui.admin.nav.relying_parties = "Apps (RP)"
diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml
index 4f89db40..f35f45e9 100644
--- a/adminfront/src/locales/ko.toml
+++ b/adminfront/src/locales/ko.toml
@@ -1308,3 +1308,4 @@ verify = "본인인증"
action = "로그인하기"
msg.admin.logout_confirm = "로그아웃 하시겠습니까?"
ui.admin.nav.logout = "로그아웃"
+ui.admin.nav.relying_parties = "애플리케이션(RP)"
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 526cdc0b..05e0347d 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -256,15 +256,16 @@ func main() {
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
- auditHandler := handler.NewAuditHandler(auditRepo)
- authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
- adminHandler := handler.NewAdminHandler()
- devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
- tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
- tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
- relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
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)
+ relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -559,6 +560,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
+ admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission)
// Tenant Management (Super Admin Only)
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
@@ -567,6 +569,9 @@ 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)
// Tenant Group Management (Super Admin Only)
admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
@@ -576,9 +581,15 @@ func main() {
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)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
+ admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners)
+ admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner)
+ admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner)
// Relying Party Management (Tenant Context)
admin.Post("/tenants/:tenantId/relying-parties",
@@ -703,4 +714,4 @@ func main() {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
-}
+}
\ No newline at end of file
diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go
index 04c2805e..45b63c75 100644
--- a/backend/internal/handler/admin_handler.go
+++ b/backend/internal/handler/admin_handler.go
@@ -1,22 +1,51 @@
package handler
import (
+ "baron-sso-backend/internal/service"
"runtime"
"time"
"github.com/gofiber/fiber/v2"
)
-type AdminHandler struct{}
+type AdminHandler struct {
+ Keto service.KetoService
+}
-func NewAdminHandler() *AdminHandler {
- return &AdminHandler{}
+func NewAdminHandler(keto service.KetoService) *AdminHandler {
+ return &AdminHandler{Keto: keto}
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
}
+func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error {
+ namespace := c.Query("namespace")
+ object := c.Query("object")
+ relation := c.Query("relation")
+ subject := c.Query("subject")
+
+ if namespace == "" || object == "" || relation == "" || subject == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"})
+ }
+
+ allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{
+ "allowed": allowed,
+ "query": fiber.Map{
+ "namespace": namespace,
+ "object": object,
+ "relation": relation,
+ "subject": subject,
+ },
+ })
+}
+
// GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats
diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go
index 87d9b16f..ac232a11 100644
--- a/backend/internal/handler/dev_handler.go
+++ b/backend/internal/handler/dev_handler.go
@@ -166,6 +166,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // Set for audit logging
+ if tid, ok := client.Metadata["tenant_id"].(string); ok {
+ c.Locals("tenant_id", tid)
+ }
+
summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
@@ -239,6 +244,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"})
}
+ // Set for audit logging
+ c.Locals("tenant_id", targetTenantID)
+
// Validate Permission
isAllowed := false
if profile.Role == domain.RoleSuperAdmin {
@@ -371,6 +379,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // Set for audit logging
+ if tid, ok := current.Metadata["tenant_id"].(string); ok {
+ c.Locals("tenant_id", tid)
+ }
+
clientType := ""
if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
@@ -446,6 +459,14 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
+ // Fetch first for audit log tenant_id
+ client, err := h.Hydra.GetClient(c.Context(), clientID)
+ if err == nil {
+ if tid, ok := client.Metadata["tenant_id"].(string); ok {
+ c.Locals("tenant_id", tid)
+ }
+ }
+
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -625,6 +646,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // Set for audit logging
+ if tid, ok := current.Metadata["tenant_id"].(string); ok {
+ c.Locals("tenant_id", tid)
+ }
+
// 3. Update Hydra
current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
diff --git a/backend/internal/handler/relying_party_handler.go b/backend/internal/handler/relying_party_handler.go
index 29611b23..8a9b448a 100644
--- a/backend/internal/handler/relying_party_handler.go
+++ b/backend/internal/handler/relying_party_handler.go
@@ -10,10 +10,11 @@ import (
type RelyingPartyHandler struct {
Service service.RelyingPartyService
+ UserSvc *service.KratosAdminService
}
-func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler {
- return &RelyingPartyHandler{Service: s}
+func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler {
+ return &RelyingPartyHandler{Service: s, UserSvc: userSvc}
}
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
@@ -110,3 +111,58 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
+
+func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error {
+ clientID := c.Params("id")
+ subjects, err := h.Service.ListOwners(c.Context(), clientID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ type ownerInfo struct {
+ Subject string `json:"subject"`
+ Name string `json:"name,omitempty"`
+ Email string `json:"email,omitempty"`
+ Type string `json:"type"` // "user" or "group"
+ }
+
+ owners := make([]ownerInfo, 0, len(subjects))
+ for _, s := range subjects {
+ info := ownerInfo{Subject: s, Type: "unknown"}
+ if len(s) > 5 && s[:5] == "User:" {
+ info.Type = "user"
+ userID := s[5:]
+ identity, err := h.UserSvc.GetIdentity(c.Context(), userID)
+ if err == nil && identity != nil {
+ info.Name, _ = identity.Traits["name"].(string)
+ info.Email, _ = identity.Traits["email"].(string)
+ }
+ } else if len(s) > 10 && s[:10] == "UserGroup:" {
+ info.Type = "group"
+ // Group name enrichment could be added if we have a GroupService here
+ }
+ owners = append(owners, info)
+ }
+
+ return c.JSON(owners)
+}
+
+func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error {
+ clientID := c.Params("id")
+ subject := c.Params("subject") // e.g. "User:uuid"
+
+ if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "owner added"})
+}
+
+func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error {
+ clientID := c.Params("id")
+ subject := c.Params("subject")
+
+ if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "owner removed"})
+}
diff --git a/backend/internal/handler/tenant_group_handler.go b/backend/internal/handler/tenant_group_handler.go
index bcf4568c..5f21df88 100644
--- a/backend/internal/handler/tenant_group_handler.go
+++ b/backend/internal/handler/tenant_group_handler.go
@@ -9,11 +9,12 @@ import (
)
type TenantGroupHandler struct {
- Service service.TenantGroupService
+ Service service.TenantGroupService
+ UserService *service.KratosAdminService
}
-func NewTenantGroupHandler(svc service.TenantGroupService) *TenantGroupHandler {
- return &TenantGroupHandler{Service: svc}
+func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
+ return &TenantGroupHandler{Service: svc, UserService: userSvc}
}
type tenantGroupSummary struct {
@@ -120,6 +121,59 @@ func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) 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 {
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index 01cede71..89d43663 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -15,10 +15,11 @@ type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
Keto service.KetoService
+ UserSvc *service.KratosAdminService
}
-func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService) *TenantHandler {
- return &TenantHandler{DB: db, Service: svc, Keto: keto}
+func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler {
+ return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc}
}
type tenantSummary struct {
@@ -327,6 +328,58 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
+func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
+ tenantID := c.Params("id")
+ userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID)
+ 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.UserSvc.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 {
+ admins = append(admins, adminInfo{ID: uid})
+ }
+ }
+
+ return c.JSON(admins)
+}
+
+func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
+ tenantID := c.Params("id")
+ userID := c.Params("userId")
+
+ if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "admin added to tenant"})
+}
+
+func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
+ tenantID := c.Params("id")
+ userID := c.Params("userId")
+
+ if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "admin removed from tenant"})
+}
+
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {
diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go
index 636918b7..94ec851b 100644
--- a/backend/internal/service/relying_party_service.go
+++ b/backend/internal/service/relying_party_service.go
@@ -16,6 +16,9 @@ type RelyingPartyService interface {
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
Delete(ctx context.Context, clientID string) error
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
+ AddOwner(ctx context.Context, clientID, subject string) error
+ RemoveOwner(ctx context.Context, clientID, subject string) error
+ ListOwners(ctx context.Context, clientID string) ([]string, error)
}
type relyingPartyService struct {
@@ -163,6 +166,27 @@ func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clien
return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation)
}
+func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error {
+ return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject)
+}
+
+func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error {
+ return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject)
+}
+
+func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
+ tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "")
+ if err != nil {
+ return nil, err
+ }
+
+ subjects := make([]string, 0, len(tuples))
+ for _, t := range tuples {
+ subjects = append(subjects, t.SubjectID)
+ }
+ return subjects, nil
+}
+
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
if client == nil {
return nil
diff --git a/backend/internal/service/tenant_group_service.go b/backend/internal/service/tenant_group_service.go
index d295111a..bafd32bc 100644
--- a/backend/internal/service/tenant_group_service.go
+++ b/backend/internal/service/tenant_group_service.go
@@ -15,6 +15,9 @@ type TenantGroupService interface {
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 {
@@ -92,3 +95,36 @@ func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID,
}
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 c6a5d954..e4ea83dd 100644
--- a/backend/internal/service/tenant_service.go
+++ b/backend/internal/service/tenant_service.go
@@ -21,6 +21,9 @@ type TenantService interface {
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가
+ AddTenantAdmin(ctx context.Context, tenantID, userID string) error
+ RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error
+ ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error)
}
type tenantService struct {
@@ -208,3 +211,35 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return s.repo.FindBySlug(ctx, slug)
}
+
+func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
+ if s.keto == nil {
+ return errors.New("keto service not initialized")
+ }
+ return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
+}
+
+func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
+ if s.keto == nil {
+ return errors.New("keto service not initialized")
+ }
+ return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
+}
+
+func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
+ if s.keto == nil {
+ return nil, errors.New("keto service not initialized")
+ }
+ tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "")
+ if err != nil {
+ return nil, err
+ }
+
+ userIDs := make([]string, 0, len(tuples))
+ for _, t := range tuples {
+ if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
+ userIDs = append(userIDs, t.SubjectID[5:])
+ }
+ }
+ return userIDs, nil
+}