From 18e9a2aa4a42c8683989dc0eaa4ed8026101ee54 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 22 Apr 2026 11:41:01 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=8B=A0=EC=B2=AD=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/domain/developer_request.go | 25 +++++++ backend/internal/service/developer_service.go | 73 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 backend/internal/domain/developer_request.go create mode 100644 backend/internal/service/developer_service.go diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 647bea2c..c1376494 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -42,6 +42,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientConsent{}, &domain.KetoOutbox{}, &domain.SharedLink{}, + &domain.DeveloperRequest{}, // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto ) } diff --git a/backend/internal/domain/developer_request.go b/backend/internal/domain/developer_request.go new file mode 100644 index 00000000..73c8b5c5 --- /dev/null +++ b/backend/internal/domain/developer_request.go @@ -0,0 +1,25 @@ +package domain + +import ( + "time" +) + +const ( + DeveloperRequestStatusPending = "pending" + DeveloperRequestStatusApproved = "approved" + DeveloperRequestStatusRejected = "rejected" +) + +// DeveloperRequest represents a user's application to become a developer. +type DeveloperRequest struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID + TenantID string `gorm:"index;not null" json:"tenantId"` + Name string `gorm:"not null" json:"name"` + Organization string `json:"organization"` + Reason string `json:"reason"` + Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected + AdminNotes string `json:"adminNotes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/service/developer_service.go b/backend/internal/service/developer_service.go new file mode 100644 index 00000000..8097b023 --- /dev/null +++ b/backend/internal/service/developer_service.go @@ -0,0 +1,73 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + + "gorm.io/gorm" +) + +type DeveloperService struct { + db *gorm.DB +} + +func NewDeveloperService(db *gorm.DB) *DeveloperService { + return &DeveloperService{db: db} +} + +func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error { + // Check if there is already a pending request + var existing domain.DeveloperRequest + err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error + if err == nil { + return errors.New("already has a pending request") + } + + return s.db.WithContext(ctx).Create(&req).Error +} + +func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) { + var req domain.DeveloperRequest + err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Order("created_at DESC").First(&req).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &req, nil +} + +func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) { + var req domain.DeveloperRequest + err := s.db.WithContext(ctx).First(&req, id).Error + if err != nil { + return nil, err + } + return &req, nil +} + +func (s *DeveloperService) ListRequests(ctx context.Context, status string) ([]domain.DeveloperRequest, error) { + var requests []domain.DeveloperRequest + query := s.db.WithContext(ctx) + if status != "" { + query = query.Where("status = ?", status) + } + err := query.Order("created_at DESC").Find(&requests).Error + return requests, err +} + +func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error { + return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{ + "status": domain.DeveloperRequestStatusApproved, + "admin_notes": adminNotes, + }).Error +} + +func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error { + return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{ + "status": domain.DeveloperRequestStatusRejected, + "admin_notes": adminNotes, + }).Error +} From 4139bb70644320928b3ff64ff20dfd0f751505c6 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 22 Apr 2026 11:41:44 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20RP=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20Keto=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=B6=80=EC=97=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 9 ++- backend/internal/handler/admin_handler.go | 93 ++++++++++++++++++++++- backend/internal/handler/dev_handler.go | 84 ++++++++++++++++++++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f82ff3af..5fdd5e69 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -285,12 +285,13 @@ func main() { relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo) secretRepo := repository.NewClientSecretRepository(db) consentRepo := repository.NewClientConsentRepository(db) + developerService := service.NewDeveloperService(db) auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler.HeadlessJWKS = headlessJWKSCache - adminHandler := handler.NewAdminHandler(ketoService) - devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, authHandler) + adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo, developerService) + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) @@ -723,6 +724,10 @@ func main() { dev.Delete("/consents", devHandler.RevokeConsents) dev.Get("/audit-logs", devHandler.ListAuditLogs) + // [New] Developer Registration Flow + dev.Post("/developer-request", devHandler.RequestDeveloperAccess) + dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus) + // Webhook for Kratos courier (HTTP delivery) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 8a300bce..b334746a 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,19 +1,29 @@ package handler import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "log/slog" "runtime" + "strconv" "time" "github.com/gofiber/fiber/v2" ) type AdminHandler struct { - Keto service.KetoService + Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository + DeveloperSvc *service.DeveloperService } -func NewAdminHandler(keto service.KetoService) *AdminHandler { - return &AdminHandler{Keto: keto} +func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository, developerSvc *service.DeveloperService) *AdminHandler { + return &AdminHandler{ + Keto: keto, + KetoOutbox: ketoOutbox, + DeveloperSvc: developerSvc, + } } func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { @@ -39,3 +49,80 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(stats) } + +func (h *AdminHandler) ListDeveloperRequests(c *fiber.Ctx) error { + status := c.Query("status") + requests, err := h.DeveloperSvc.ListRequests(c.Context(), status) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + return c.JSON(requests) +} + +func (h *AdminHandler) ApproveDeveloperRequest(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request id") + } + + var reqBody struct { + AdminNotes string `json:"adminNotes"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + // 1. Get request to know userID and tenantID + devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details") + } + + // 2. Approve in DB + if err := h.DeveloperSvc.ApproveRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + // 3. Grant Keto Permissions via Outbox + if h.KetoOutbox != nil { + subject := "User:" + devReq.UserID + permissions := []string{"view_dev_console", "grant_dev_permissions"} + + for _, relation := range permissions { + err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: devReq.TenantID, + Relation: relation, + Subject: subject, + Action: domain.KetoOutboxActionCreate, + }) + if err != nil { + slog.Warn("failed to create keto outbox for developer approval", "relation", relation, "userID", devReq.UserID, "error", err) + } + } + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *AdminHandler) RejectDeveloperRequest(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request id") + } + + var reqBody struct { + AdminNotes string `json:"adminNotes"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if err := h.DeveloperSvc.RejectRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index fbbc67e3..5282b667 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -34,6 +34,7 @@ type DevHandler struct { KetoOutbox repository.KetoOutboxRepository RPSvc service.RelyingPartyService TenantSvc service.TenantService + DeveloperSvc *service.DeveloperService Auth interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } @@ -47,6 +48,7 @@ func NewDevHandler( keto service.KetoService, ketoOutbox repository.KetoOutboxRepository, tenantSvc service.TenantService, + developerSvc *service.DeveloperService, auth ...interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) }, @@ -70,6 +72,7 @@ func NewDevHandler( KetoOutbox: ketoOutbox, RPSvc: rpSvc, TenantSvc: tenantSvc, + DeveloperSvc: developerSvc, Auth: authProvider, } } @@ -1551,6 +1554,22 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { } h.syncHeadlessJWKSCache(c.Context(), *created, "client_create") + // [New] Automatically grant admin permission to the creator in Keto + if h.KetoOutbox != nil && profile != nil { + err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "RelyingParty", + Object: created.ClientID, + Relation: "admins", + Subject: "User:" + profile.ID, + Action: domain.KetoOutboxActionCreate, + }) + if err != nil { + slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err) + } else { + slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID) + } + } + // Store secret in metadata for later retrieval if created.ClientSecret != "" { // 1. Store in PostgreSQL (Source of Truth) @@ -2781,3 +2800,68 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error { return c.JSON(tenants) } + +func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + + var req struct { + Name string `json:"name"` + Organization string `json:"organization"` + Reason string `json:"reason"` + TenantID string `json:"tenantId"` + } + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if req.TenantID == "" && profile.TenantID != nil { + req.TenantID = *profile.TenantID + } + if req.TenantID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") + } + + devReq := domain.DeveloperRequest{ + UserID: profile.ID, + TenantID: req.TenantID, + Name: req.Name, + Organization: req.Organization, + Reason: req.Reason, + Status: domain.DeveloperRequestStatusPending, + } + + if err := h.DeveloperSvc.RequestAccess(c.Context(), devReq); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "ok"}) +} + +func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + + tenantID := c.Query("tenantId") + if tenantID == "" && profile.TenantID != nil { + tenantID = *profile.TenantID + } + if tenantID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") + } + + status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), profile.ID, tenantID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + if status == nil { + return c.JSON(fiber.Map{"status": "none"}) + } + + return c.JSON(status) +} From 4dc274a5d791c01dabe1b267c6005307ec207185 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 22 Apr 2026 11:42:02 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D=20=EB=8C=80=EC=9D=91?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EC=8B=A0=EC=B2=AD=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=A7=81=ED=81=AC=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 217 +++++++++++++++++- devfront/src/lib/devApi.ts | 43 ++++ 2 files changed, 249 insertions(+), 11 deletions(-) diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index d47cd449..1f75d072 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,12 +1,14 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { BookOpenText, + Clock, Filter, Plus, Search, ServerCog, ShieldHalf, + X, } from "lucide-react"; import { useState } from "react"; import { useAuth } from "react-oidc-context"; @@ -27,6 +29,7 @@ import { CardTitle, } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; import { Separator } from "../../components/ui/separator"; import { Table, @@ -36,7 +39,14 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; -import { fetchClients, fetchDevStats } from "../../lib/devApi"; +import { Textarea } from "../../components/ui/textarea"; +import { + type DeveloperRequest, + fetchClients, + fetchDevStats, + fetchDeveloperRequestStatus, + requestDeveloperAccess, +} from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; @@ -45,9 +55,10 @@ function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); const hasAccessToken = Boolean(auth.user?.access_token); - const role = resolveProfileRole( - auth.user?.profile as Record | undefined, - ); + const userProfile = auth.user?.profile as Record | undefined; + const role = resolveProfileRole(userProfile); + const tenantId = userProfile?.tenant_id as string | undefined; + const canCreateClient = role !== "user" && role !== "tenant_member"; const { @@ -66,10 +77,21 @@ function ClientsPage() { enabled: hasAccessToken, }); + const { + data: requestStatus, + isLoading: isLoadingRequest, + refetch: refetchRequest, + } = useQuery({ + queryKey: ["developer-request", tenantId], + queryFn: () => fetchDeveloperRequestStatus(tenantId), + enabled: hasAccessToken && role === "user", + }); + const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); + const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); const clients = data?.items || []; @@ -128,7 +150,7 @@ function ClientsPage() { }, ]; - const isLoading = isLoadingClients || isLoadingStats; + const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest; if (auth.isLoading || !hasAccessToken || isLoading) { return ( @@ -154,6 +176,8 @@ function ClientsPage() { ); } + const devStatus = (requestStatus as DeveloperRequest)?.status || "none"; + return (
@@ -372,12 +396,44 @@ function ClientsPage() { "조회 가능한 RP가 없습니다.", )}

-

- {t( - "msg.dev.clients.empty_detail", - "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", +

+

+ {t( + "msg.dev.clients.empty_detail", + "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", + )} +

+ {role === "user" && devStatus === "none" && ( + )} -

+ {role === "user" && devStatus === "pending" && ( +

+ + {t("ui.dev.request.pending.title", "심사 진행 중")} +

+ )} + {role === "user" && devStatus === "rejected" && ( + + )} +
@@ -552,6 +608,145 @@ function ClientsPage() { + + setIsRequestModalOpen(false)} + onSuccess={() => { + refetchRequest(); + setIsRequestModalOpen(false); + }} + tenantId={tenantId || ""} + initialName={(userProfile?.name as string) || ""} + initialOrg={(userProfile?.companyCode as string) || ""} + /> + + ); +} + +interface RequestAccessModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + tenantId: string; + initialName: string; + initialOrg: string; +} + +function RequestAccessModal({ + isOpen, + onClose, + onSuccess, + tenantId, + initialName, + initialOrg, +}: RequestAccessModalProps) { + const [name, setName] = useState(initialName); + const [organization, setOrganization] = useState(initialOrg); + const [reason, setReason] = useState(""); + + const mutation = useMutation({ + mutationFn: requestDeveloperAccess, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate({ + name, + organization, + reason, + tenantId, + }); + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+

+ {t("ui.dev.request.modal.title", "개발자 등록 신청")} +

+

+ {t( + "msg.dev.request.modal.desc", + "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.", + )} +

+
+ +
+ +
+
+
+ + setName(e.target.value)} + required + /> +
+
+ + setOrganization(e.target.value)} + required + /> +
+
+ +