From 12b1bc4aca2da1ac6b16323b1829228045276645 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 23 Feb 2026 16:47:41 +0900 Subject: [PATCH] =?UTF-8?q?PRIVATE=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B6=8C=ED=95=9C=20=ED=8C=90=EC=A0=95=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 +- backend/internal/handler/dev_handler.go | 150 +++++++++++++++++++++++- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4c3c9518..2d019cc7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -268,7 +268,7 @@ func main() { 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, ketoService) + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 9422b802..e25ceeca 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -7,9 +7,11 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" "log/slog" + "os" "strings" "time" @@ -25,9 +27,28 @@ type DevHandler struct { ConsentRepo repository.ClientConsentRepository Keto service.KetoService RPSvc service.RelyingPartyService + Auth interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + } } -func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService, keto service.KetoService) *DevHandler { +func NewDevHandler( + redis domain.RedisRepository, + secretRepo domain.ClientSecretRepository, + consentRepo repository.ClientConsentRepository, + rpSvc service.RelyingPartyService, + keto service.KetoService, + auth ...interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + }, +) *DevHandler { + var authProvider interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + } + if len(auth) > 0 { + authProvider = auth[0] + } + return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, @@ -36,6 +57,7 @@ func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretR ConsentRepo: consentRepo, Keto: keto, RPSvc: rpSvc, + Auth: authProvider, } } @@ -101,24 +123,140 @@ type clientUpsertRequest struct { func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) - if !ok || profile == nil { + if (!ok || profile == nil) && h.Auth != nil { + enriched, err := h.Auth.GetEnrichedProfile(c) + if err == nil && enriched != nil { + profile = enriched + ok = true + c.Locals("user_profile", enriched) + } + } + if ok && profile != nil { + // Super Admin bypass + if profile.Role == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) + return true, nil + } + if isAdminEmail(profile.Email) { + slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email) + return true, nil + } + + // Check with Keto: System:AppManager#member + allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member") + if err != nil { + return false, err + } + slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed) + + return allowed, nil + } + + tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization")) + if isAdminEmail(tokenEmail) { + slog.Info("Dev private permission granted by token email", "email", tokenEmail) + return true, nil + } + if tokenSubject == "" { + if isTrustedLocalDevfrontRequest(c) { + // Local devfront fallback: allow localhost developer flow even if auth context is missing. + slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin")) + return true, nil + } return false, nil } - // Super Admin bypass - if profile.Role == domain.RoleSuperAdmin { - return true, nil + // Fallback: resolve role from Kratos identity traits when user_profile is not injected. + if h.KratosAdmin != nil { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject) + if err == nil && identity != nil { + if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject) + return true, nil + } + if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) { + slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email) + return true, nil + } + } } // Check with Keto: System:AppManager#member - allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member") + allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member") if err != nil { return false, err } + slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed) return allowed, nil } +func extractAuthClaimsFromBearer(authHeader string) (string, string) { + authHeader = strings.TrimSpace(authHeader) + if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return "", "" + } + + token := strings.TrimSpace(authHeader[len("Bearer "):]) + if token == "" || strings.Count(token, ".") != 2 { + return "", "" + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", "" + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + payload, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "" + } + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return "", "" + } + sub := "" + if sub, ok := claims["sub"].(string); ok { + sub = strings.TrimSpace(sub) + } + email := "" + if claimEmail, ok := claims["email"].(string); ok { + email = strings.TrimSpace(claimEmail) + } + + return sub, email +} + +func isAdminEmail(email string) bool { + adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) + return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail) +} + +func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool { + if c == nil { + return false + } + + origin := strings.ToLower(strings.TrimSpace(c.Get("Origin"))) + referer := strings.ToLower(strings.TrimSpace(c.Get("Referer"))) + allowedPrefixes := []string{ + "http://localhost:5174", + "https://localhost:5174", + } + + for _, prefix := range allowedPrefixes { + if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) { + return true + } + } + + return false +} + func (h *DevHandler) ListClients(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0)