From 4139bb70644320928b3ff64ff20dfd0f751505c6 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 22 Apr 2026 11:41:44 +0900 Subject: [PATCH] =?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) +}