package main import ( "baron-sso-backend/internal/bootstrap" "baron-sso-backend/internal/domain" "baron-sso-backend/internal/handler" "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/validator" "errors" "fmt" "log" "log/slog" "os" "strconv" "strings" "time" "github.com/bwmarrin/snowflake" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/encryptcookie" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v2/middleware/requestid" "github.com/joho/godotenv" "gorm.io/driver/postgres" "gorm.io/gorm" gormLogger "gorm.io/gorm/logger" ) func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } func normalizeDocsPrefix(prefix string) string { trimmed := strings.TrimSpace(prefix) if trimmed == "" || trimmed == "/" { return "" } if !strings.HasPrefix(trimmed, "/") { trimmed = "/" + trimmed } return strings.TrimRight(trimmed, "/") } func registerDocsRoutes(app *fiber.App, prefix string) { base := normalizeDocsPrefix(prefix) docsPath := base + "/docs" redocPath := base + "/redoc" openapiPath := base + "/openapi.yaml" app.Get(docsPath, func(c *fiber.Ctx) error { return c.SendFile("./docs/swagger-ui/index.html") }) app.Get(docsPath+"/", func(c *fiber.Ctx) error { return c.SendFile("./docs/swagger-ui/index.html") }) app.Static(docsPath, "./docs/swagger-ui") app.Get(redocPath, func(c *fiber.Ctx) error { return c.SendFile("./docs/redoc/index.html") }) app.Get(redocPath+"/", func(c *fiber.Ctx) error { return c.SendFile("./docs/redoc/index.html") }) app.Static(redocPath, "./docs/redoc") app.Get(openapiPath, func(c *fiber.Ctx) error { c.Type("yaml") return c.SendFile("./docs/openapi.yaml") }) } func main() { // Load .env file from possible paths // 1. .env (Current Directory) // 2. ../.env (Project Root when running from backend/) // 3. ../../.env (Project Root when running from backend/cmd/server/) if err := godotenv.Load(".env"); err != nil { if err := godotenv.Load("../.env"); err != nil { godotenv.Load("../../.env") } } // 0. Initialize Logger logger.Init(logger.Config{ ServiceName: "baron-sso", Environment: getEnv("GO_ENV", "dev"), }) // Initialize Snowflake Node (Node 2 for Baron) node, err := snowflake.NewNode(2) if err != nil { slog.Error("Failed to initialize snowflake node", "error", err) os.Exit(1) } // 1. Log Config on Startup fmt.Println("============================================================") fmt.Println(` |\__/,| (\ _.|o o |_ ) ) -(((---(((-------- `) fmt.Println("๐Ÿš€ Baron SSO Backend Starting...") slog.Info("Service starting", "service", "baron-sso", "app_env", getEnv("APP_ENV", "dev"), "db_port", getEnv("DB_PORT", "5532"), "backend_port", getEnv("BACKEND_PORT", "3000"), "userfront_port", getEnv("USERFRONT_PORT", "5000"), "userfront_url", getEnv("USERFRONT_URL", "http://sso.hmac.kr"), "redis_addr", getEnv("REDIS_ADDR", "redis:6379"), ) // --- Fail-Fast Schema Validation --- // ํŒฉํ† ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ IDP ๊ณต๊ธ‰์ž๋ฅผ ์ดˆ๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค. idpProvider, err := idp.InitializeProvider() if err != nil { slog.Error("โŒ [CRITICAL] Failed to initialize IDP Provider", "error", err) os.Exit(1) } if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil { slog.Error("โŒ [CRITICAL] Broker Schema Mismatch", "idp", idpProvider.Name(), "error", err, ) fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err) os.Exit(1) // Break the build/deployment } slog.Info("โœ… IDP Schema Validation Passed", "idp", idpProvider.Name()) // ----------------------------------- if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil { slog.Error("โŒ Admin identity seed failed", "error", err) } // 2. Initialize DB Connections // ClickHouse chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) chUser := getEnv("CLICKHOUSE_USER", "baron") chPass := getEnv("CLICKHOUSE_PASSWORD", "password") chDB := getEnv("CLICKHOUSE_DB", "baron_sso") var auditRepo domain.AuditRepository if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) auditRepo = nil // Explicitly set to nil interface } else { auditRepo = repo slog.Info("โœ… Connected to ClickHouse") } var oathkeeperRepo domain.OathkeeperLogRepository oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse") oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000")) oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory") oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass") oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory") if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil { slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err) oathkeeperRepo = nil } else { oathkeeperRepo = repo slog.Info("โœ… Connected to Ory ClickHouse") } redisService, err := service.NewRedisService() if err != nil { slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) } ketoService := service.NewKetoService() // PostgreSQL (Meta Store) pgHost := getEnv("DB_HOST", "localhost") pgPort := getEnv("DB_PORT", "5432") pgUser := getEnv("DB_USER", "baron") pgPass := getEnv("DB_PASSWORD", "password") pgName := getEnv("DB_NAME", "baron_sso") dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", pgHost, pgUser, pgPass, pgName, pgPort) gormLog := gormLogger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), gormLogger.Config{ SlowThreshold: time.Second, LogLevel: gormLogger.Warn, IgnoreRecordNotFoundError: true, Colorful: true, }, ) db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormLog, }) if err != nil { slog.Error("โŒ Failed to connect to PostgreSQL", "error", err) os.Exit(1) } else { slog.Info("โœ… Connected to PostgreSQL") // Run Bootstrap (Migrations & Seeding) if err := bootstrap.Run(db); err != nil { slog.Error("โŒ Bootstrap failed", "error", err) } // [New] Sync existing data to Keto if ketoService != nil { if err := bootstrap.SyncKetoRelations(db, ketoService); err != nil { slog.Warn("โš ๏ธ Keto synchronization failed during startup", "error", err) } } } // Oathkeeper ์ƒํƒœ๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ ํ™•์ธํ•ด ๋‹ค์šด์„ ๊ฐ์ง€ํ•ฉ๋‹ˆ๋‹ค. var oathkeeperProbe *HTTPProbe if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" { intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10")) if err != nil || intervalSec <= 0 { intervalSec = 10 } timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2")) if err != nil || timeoutSec <= 0 { timeoutSec = 2 } oathkeeperProbe = NewHTTPProbe( "oathkeeper", getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"), time.Duration(intervalSec)*time.Second, time.Duration(timeoutSec)*time.Second, ) oathkeeperProbe.Start() } else { slog.Info("Oathkeeper probe disabled") } // 2. Initialize Handlers tenantRepo := repository.NewTenantRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) userRepo := repository.NewUserRepository(db) 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) consentRepo := repository.NewClientConsentRepository(db) 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) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") app := fiber.New(fiber.Config{ AppName: "Baron SSO Backend", DisableStartupMessage: true, // Clean logs ReadBufferSize: 32768, // 32KB๋กœ ์ฆ๊ฐ€ (๊ธด OIDC ์ฑŒ๋ฆฐ์ง€ ๋Œ€์‘) // Global Error Handler for Production Masking ErrorHandler: func(c *fiber.Ctx, err error) error { // Default status code code := fiber.StatusInternalServerError // Check if it's a known fiber.Error var e *fiber.Error if errors.As(err, &e) { code = e.Code } // In production or stage, mask detailed 500+ errors if appEnv == "production" || appEnv == "stage" { if code >= 500 { // Log the actual error for developers slog.Error("Internal Server Error", "error", err.Error(), "path", c.Path(), "method", c.Method(), ) // Return masked message return c.Status(code).JSON(fiber.Map{ "error": "Internal Server Error", }) } } // For development or non-500 errors, return the actual error message return c.Status(code).JSON(fiber.Map{ "error": err.Error(), }) }, }) // Middleware app.Use(requestid.New(requestid.Config{ Generator: func() string { return node.Generate().String() }, })) // [Standardized] HTTP Request Logger Middleware using slog app.Use(func(c *fiber.Ctx) error { start := time.Now() // Handle request err := c.Next() // Log after request latency := time.Since(start) status := c.Response().StatusCode() path := c.Path() // Skip logging for all successful requests (status < 400) if status < 400 { return err } msg := "http_request" if err != nil { msg = "http_request_error" } slog.Info(msg, "status", status, "method", c.Method(), "path", path, "latency", latency.String(), "ip", c.IP(), "req_id", c.GetRespHeader(fiber.HeaderXRequestID), ) return err }) app.Use(recover.New(recover.Config{ EnableStackTrace: true, })) allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000") allowCredentials := allowedOrigins != "*" app.Use(cors.New(cors.Config{ AllowOrigins: allowedOrigins, AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", AllowCredentials: allowCredentials, })) // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256 cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!") if len(cookieSecret) != 32 { slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret)) if len(cookieSecret) > 32 { cookieSecret = cookieSecret[:32] } else { // Pad with '0' if too short cookieSecret = fmt.Sprintf("%-32s", cookieSecret) } } app.Use(encryptcookie.New(encryptcookie.Config{ Key: cookieSecret, })) // [Security] Disable Swagger/ReDoc in Production if appEnv != "production" { docsPrefix := getEnv("DOCS_BASE_PATH", "/api") registerDocsRoutes(app, "") if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" { registerDocsRoutes(app, normalized) } slog.Info("๐Ÿ“š API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix) } else { slog.Info("๐Ÿ”’ API Docs disabled in production") } // Routes app.Get("/", func(c *fiber.Ctx) error { return c.SendString("Baron SSO Audit Backend Online") }) app.Get("/health", func(c *fiber.Ctx) error { status := "ok" checks := make(map[string]string) // Check ClickHouse if auditRepo != nil { if err := auditRepo.Ping(c.Context()); err != nil { checks["clickhouse"] = "error: " + err.Error() status = "error" } else { checks["clickhouse"] = "ok" } } else { checks["clickhouse"] = "not_initialized" status = "degraded" } // Check Redis if redisService != nil { if err := redisService.Ping(c.Context()); err != nil { checks["redis"] = "error: " + err.Error() status = "error" } else { checks["redis"] = "ok" } } else { checks["redis"] = "not_initialized" status = "degraded" } // Check Oathkeeper if oathkeeperProbe != nil { snapshot := oathkeeperProbe.Snapshot() switch snapshot.Status { case "ok": checks["oathkeeper"] = "ok" case "": checks["oathkeeper"] = "unknown" if status != "error" { status = "degraded" } default: if snapshot.Error == "" { checks["oathkeeper"] = "error" } else { checks["oathkeeper"] = "error: " + snapshot.Error } status = "error" } } else { checks["oathkeeper"] = "disabled" if status != "error" { status = "degraded" } } if status == "error" { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "status": status, "checks": checks, }) } return c.JSON(fiber.Map{ "status": status, "checks": checks, }) }) // API Group api := app.Group("/api/v1") workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5")) queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000")) api.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, ExcludePaths: map[string]struct{}{ "/api/v1/audit": {}, "/api/v1/client-log": {}, }, BodyDump: true, WorkerCount: workerCount, QueueSize: queueSize, })) api.Post("/audit", auditHandler.CreateLog) api.Get("/audit", auditHandler.ListLogs) api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline) // Public Tenant Registration api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) // Auth Proxy Routes auth := api.Group("/auth") auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/password/login", authHandler.PasswordLogin) auth.Get("/consent", authHandler.GetConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest) auth.Post("/consent/reject", authHandler.RejectConsentRequest) auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest) auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/password/login", authHandler.PasswordLogin) auth.Get("/consent", authHandler.GetConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) // [Added] Use POST for actual verification triggered by the user auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken) auth.Post("/password/reset/complete", authHandler.CompletePasswordReset) auth.Get("/password/policy", authHandler.GetPasswordPolicy) auth.Post("/sms", authHandler.SendSms) auth.Post("/verify-sms", authHandler.VerifySms) auth.Post("/qr/init", authHandler.InitQRLogin) auth.Post("/qr/poll", authHandler.PollQRLogin) auth.Post("/qr/approve", authHandler.ScanQRLogin) // Signup Routes signup := auth.Group("/signup") signup.Post("/check-email", authHandler.CheckEmail) signup.Post("/send-email-code", authHandler.SendSignupEmailCode) signup.Post("/send-sms-code", authHandler.SendSignupSmsCode) signup.Post("/verify-code", authHandler.VerifySignupCode) signup.Post("/", authHandler.Signup) // User Routes (My Page) user := api.Group("/user") user.Get("/me", authHandler.GetMe) user.Put("/me", authHandler.UpdateMe) user.Post("/me/password", authHandler.ChangeMyPassword) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) user.Get("/rp/linked", authHandler.ListLinkedRps) user.Get("/rp/history", authHandler.ListRpHistory) user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp) // Admin Routes admin := api.Group("/admin") admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key ์ธ์ฆ ์ถ”๊ฐ€ // RBAC Middleware Instances requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{ AllowedRoles: []string{domain.RoleSuperAdmin}, AuthHandler: authHandler, KetoService: ketoService, }) requireAdmin := middleware.RequireRole(middleware.RBACConfig{ AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, AuthHandler: authHandler, KetoService: ketoService, }) 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) admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant) admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant) 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) 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", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), relyingPartyHandler.Create) admin.Get("/tenants/:tenantId/relying-parties", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), relyingPartyHandler.List) admin.Get("/relying-parties/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"), relyingPartyHandler.Get) admin.Put("/relying-parties/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.Update) admin.Delete("/relying-parties/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.Delete) // Admin User Management admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin์ธ ๊ฒฝ์šฐ ํ•ด๋‹น ํ…Œ๋„ŒํŠธ ์‚ฌ์šฉ์ž๋งŒ ๋ณด์ด๋„๋ก Handler ์ˆ˜์ • ํ•„์š” admin.Post("/users", requireAdmin, userHandler.CreateUser) admin.Get("/users/:id", requireAdmin, userHandler.GetUser) admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser) admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser) // API Key Management (M2M) - Super Admin Only admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys) admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey) admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) // ๊ฐœ๋ฐœ์ž ํฌํ„ธ ๋ผ์šฐํŠธ (RP/Consent ๊ด€๋ฆฌ ๋ฐ IdP ์„ค์ •) dev := api.Group("/dev", requireAdmin) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"), devHandler.GetClient) dev.Put("/clients/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), devHandler.UpdateClient) dev.Post("/clients/:id/secret/rotate", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), devHandler.RotateClientSecret) dev.Patch("/clients/:id/status", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), devHandler.UpdateClientStatus) dev.Delete("/clients/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), devHandler.DeleteClient) dev.Get("/consents", devHandler.ListConsents) dev.Delete("/consents", devHandler.RevokeConsents) // Webhook for Kratos courier (HTTP delivery) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) // Client Logging Route (Standardized & Flattened) api.Post("/client-log", func(c *fiber.Ctx) error { type LogReq struct { Level string `json:"level"` Message string `json:"message"` Data map[string]interface{} `json:"data,omitempty"` } var req LogReq if err := c.BodyParser(&req); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // Prepare attributes for flattening attrs := []any{ slog.String("source", "client"), } for k, v := range req.Data { // Skip svc if it's already set by the global logger to avoid confusion, // or keep it as client_svc if k == "svc" { attrs = append(attrs, slog.Any("client_svc", v)) } else { attrs = append(attrs, slog.Any(k, v)) } } // Map and log with correct level var level slog.Level switch req.Level { case "SEVERE", "ERROR": level = slog.LevelError case "WARNING", "WARN": level = slog.LevelWarn default: level = slog.LevelInfo } // Filter out noisy client navigation logs if level == slog.LevelInfo { msg := strings.ToLower(req.Message) if strings.Contains(msg, "navigating to") || strings.Contains(msg, "going to") || strings.Contains(msg, "redirecting to") || strings.Contains(msg, "full paths for routes") { return c.SendStatus(fiber.StatusOK) } } slog.Log(c.Context(), level, req.Message, attrs...) return c.SendStatus(fiber.StatusOK) }) // Start Server port := getEnv("BACKEND_PORT", "3000") slog.Info("Server listening", "port", port) fmt.Println("============================================================") if err := app.Listen(":" + port); err != nil { slog.Error("Server failed to start", "error", err) os.Exit(1) } }