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" "context" "fmt" "log" "log/slog" "net/url" "os" "path/filepath" "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 getEnvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) { if path := strings.TrimSpace(getEnv(fileKey, "")); path != "" { value, err := readEnvFileValue(path) if err != nil { return "", err } return value, nil } return getEnv(valueKey, fallback), nil } func readEnvFileValue(path string) (string, error) { candidates := []string{path} if !filepath.IsAbs(path) { candidates = append(candidates, filepath.Join("..", path), filepath.Join("..", "..", path)) } var lastErr error for _, candidate := range candidates { data, err := os.ReadFile(candidate) if err == nil { return string(data), nil } lastErr = err } return "", fmt.Errorf("read secret file %q: %w", path, lastErr) } func normalizeDocsPrefix(prefix string) string { trimmed := strings.TrimSpace(prefix) if trimmed == "" || trimmed == "/" { return "" } if !strings.HasPrefix(trimmed, "/") { trimmed = "/" + trimmed } return strings.TrimRight(trimmed, "/") } func shouldEnableDocs(appEnv string) bool { env := strings.ToLower(strings.TrimSpace(appEnv)) return env != "prod" && env != "production" } 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 appEnvForLogger := getEnv("APP_ENV", getEnv("GO_ENV", "dev")) logger.Init(logger.Config{ ServiceName: "baron-sso", Environment: appEnvForLogger, LevelOverride: getEnv("BACKEND_LOG_LEVEL", ""), }) // 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()) // ----------------------------------- // 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 var rpUsageProjectionRepo domain.RPUsageProjectionRepository var rpUsageQueryRepo domain.RPUsageQueryRepository 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 rpUsageProjectionRepo = repo rpUsageQueryRepo = 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] Initialize Keto Outbox and Worker ketoOutboxRepo := repository.NewKetoOutboxRepository(db) ketoRelayWorker := service.NewKetoRelayWorker(ketoOutboxRepo, ketoService) go ketoRelayWorker.Start(context.Background()) slog.Info("โœ… Keto Relay Worker started") // [Moved & Enhanced] Seed Admin Identity & Sync Local Role if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil { slog.Error("โŒ Admin identity seed failed", "error", err) } else { // Sync role to local DB if err := bootstrap.SyncAdminRole(db, kratosID); err != nil { slog.Error("โŒ Admin role sync failed", "error", err) } } // [New] Sync existing data to Keto if ketoOutboxRepo != nil { if err := bootstrap.SyncKetoRelations(db, ketoOutboxRepo); err != nil { slog.Warn("โš ๏ธ Keto synchronization queueing 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) userProjectionRepo := repository.NewUserProjectionRepository(db) ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db) worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) sharedLinkRepo := repository.NewSharedLinkRepository(db) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() userProjectionSyncer := service.NewUserProjectionSyncService(kratosAdminService, userProjectionRepo) if synced, err := userProjectionSyncer.Reconcile(context.Background()); err != nil { slog.Error("โŒ Kratos user projection sync failed", "error", err) } else { slog.Info("โœ… Kratos user projection synced", "users", synced) } tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo) worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "") if err != nil { slog.Error("Worksmobile private key file could not be loaded", "error", err) os.Exit(1) } worksmobileClient := service.NewWorksmobileHTTPClientWithAuth( getEnv("WORKS_ADMIN_ACCESS_TOKEN", getEnv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN", "")), getEnv("SAMAN_SCIM_LONGLIVE_TOKEN", ""), service.WorksmobileOAuthConfig{ ClientID: getEnv("WORKS_ADMIN_OAUTH_CLIENT_ID", ""), ClientSecret: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SECRET", ""), ServiceAccount: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""), PrivateKey: worksmobilePrivateKey, Scope: getEnv("WORKS_ADMIN_OAUTH_SCOPE", "directory"), TokenURL: getEnv("WORKS_ADMIN_OAUTH_TOKEN_URL", ""), }, ) configureWorksmobileClientFromEnv(worksmobileClient) worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient) worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient) go worksmobileRelayWorker.Start(context.Background()) slog.Info("โœ… Worksmobile Relay Worker started") rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo) if rpUsageProjectionRepo != nil { rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo) go rpUsageProjectorWorker.Start(context.Background()) slog.Info("โœ… RP Usage Projector Worker started") } else { slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable") } sharedLinkService := service.NewSharedLinkService(sharedLinkRepo) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService) tenantService.SetKetoService(ketoService) // Keto ์ฃผ์ž… hydraService := service.NewHydraAdminService() headlessJWKSCache := service.NewHeadlessJWKSCacheService(redisService, nil) headlessJWKSWorker := service.NewHeadlessJWKSCacheWorker(hydraService, headlessJWKSCache) go headlessJWKSWorker.Start(context.Background()) slog.Info("โœ… Headless JWKS Cache Worker started") relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo) secretRepo := repository.NewClientSecretRepository(db) consentRepo := repository.NewClientConsentRepository(db) rpUserMetadataRepo := repository.NewRPUserMetadataRepository(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 authHandler.UserProjectionRepo = userProjectionRepo authHandler.RPUserMetadataRepo = rpUserMetadataRepo authHandler.RPUsageSink = rpUsageEmitter adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) adminHandler.RPUsageQueries = rpUsageQueryRepo adminHandler.TenantRepo = tenantRepo adminHandler.Hydra = hydraService adminHandler.AuditRepo = auditRepo adminHandler.UserProjectionRepo = userProjectionRepo adminHandler.UserProjectionSyncer = userProjectionSyncer adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo devHandler.RPUserMetadataRepo = rpUserMetadataRepo devHandler.RPUsageQueries = rpUsageQueryRepo tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo) userHandler.UserProjectionRepo = userProjectionRepo tenantHandler.SetWorksmobileSyncer(worksmobileService) userHandler.SetWorksmobileSyncer(worksmobileService) worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService) apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "") clientDebugEnabled := logger.ClientDebugEnabled(appEnv, clientLogDebugFlag) app := fiber.New(fiber.Config{ AppName: "Baron SSO Backend", DisableStartupMessage: true, // Clean logs ReadBufferSize: 32768, // 32KB๋กœ ์ฆ๊ฐ€ (๊ธด OIDC ์ฑŒ๋ฆฐ์ง€ ๋Œ€์‘) ErrorHandler: newErrorHandler(appEnv), }) // 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, })) // Backfill `code` on legacy JSON error responses during migration period. app.Use(middleware.ErrorCodeEnricher()) allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000") userfrontURL := getEnv("USERFRONT_URL", "http://sso.hmac.kr") baseDomain := "" if u, err := url.Parse(userfrontURL); err == nil { baseDomain = u.Hostname() } app.Use(cors.New(cors.Config{ AllowOriginsFunc: func(origin string) bool { // 1. Check static allowed list for allowed := range strings.SplitSeq(allowedOrigins, ",") { if origin == strings.TrimSpace(allowed) { return true } } // Parse origin URL u, err := url.Parse(origin) if err != nil { return false } hostname := u.Hostname() // 2. Check subdomains of base domain if baseDomain != "" && (hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)) { return true } // 3. Check registered tenant domains // Use context.Background() as we don't have request context here easily allowed, _ := tenantService.IsDomainAllowed(context.Background(), hostname) return allowed }, AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Test-Role, X-Mock-Role, X-Tenant-ID", AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", AllowCredentials: true, })) // 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 shouldEnableDocs(appEnv) { 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-like environment", "app_env", appEnv) } slog.Info("Client log policy configured", "app_env", appEnv, "client_debug_enabled", clientDebugEnabled, ) // 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, }) }) rpManifestHandler := handler.NewRPManifestHandler() app.Get("/.well-known/baron-rp-manifest", rpManifestHandler.GetHTML) app.Get("/.well-known/baron-rp-manifest.json", rpManifestHandler.GetJSON) app.Get("/.well-known/baron-rp-manifest.schema.json", rpManifestHandler.GetSchema) // 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/audit/auth/timeline": {}, "/api/v1/client-log": {}, "/api/v1/dev/audit-logs": {}, }, BodyDump: true, WorkerCount: workerCount, QueueSize: queueSize, })) api.Post("/audit", auditHandler.CreateLog) api.Get("/audit", auditHandler.ListLogs) api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline) // [New] Shared Link Public API (No Auth required) api.Get("/public/orgchart", tenantHandler.GetPublicOrgChart) // Public Tenant Registration api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) api.Get("/admin/worksmobile/oauth/callback", worksmobileHandler.OAuthCallback) integrationsAPI := api.Group("/integrations") integrationsAPI.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) integrationsAPI.Get("/org-context", tenantHandler.GetOrgContext) // Tenant Context Middleware (identifies tenant from Host header) api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{ TenantService: tenantService, })) // 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.Post("/headless/password/login", authHandler.HeadlessPasswordLogin) auth.Post("/headless/link/init", authHandler.HeadlessLinkInit) auth.Post("/headless/link/poll", authHandler.HeadlessLinkPoll) auth.Get("/tenant-info", authHandler.GetTenantInfo) 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("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage) auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage) // [Added] Use POST for actual verification triggered by the user auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken) auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken) auth.Post("/password/reset/ve", 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) auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS) // Signup Routes signup := auth.Group("/signup") signup.Get("/tenants", authHandler.GetActiveTenants) signup.Post("/check-email", authHandler.CheckEmail) signup.Post("/check-login-id", authHandler.CheckLoginID) 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("/sessions", authHandler.ListMySessions) user.Delete("/sessions/:id", authHandler.DeleteMySession) 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, }) requireAnyUser := middleware.RequireRole(middleware.RBACConfig{ AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser}, AuthHandler: authHandler, KetoService: ketoService, }) admin.Get("/check", adminHandler.CheckAuth) // ๊ธฐ๋ณธ Admin ์ฒดํฌ๋Š” requireAdmin ์—†์ด ApiKeyAuth๋กœ๋งŒ ๋ณดํ˜ธ๋  ์ˆ˜ ์žˆ์Œ (๋˜๋Š” ์ถ”๊ฐ€ ๊ฐ€๋Šฅ) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity) admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs) admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs) admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus) admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection) admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection) admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily) // Tenant Management (Mixed roles, handler filters results) admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants) admin.Get("/tenants/export", requireSuperAdmin, tenantHandler.ExportTenantsCSV) admin.Post("/tenants/import", requireSuperAdmin, tenantHandler.ImportTenantsCSV) admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant) // [New] Shared Link Management admin.Post("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.CreateShareLink) admin.Get("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.ListShareLinks) admin.Delete("/share-links/:id", requireAdmin, tenantHandler.DeleteShareLink) admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk) 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) admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners) admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner) admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner) admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview) admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison) admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV) admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser) admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob) // Organization & Org-Chart Management (Tenant Admin/Super Admin) org := admin.Group("/tenants/:tenantId/organization") org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List) org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create) org.Get("/:id", userGroupHandler.Get) org.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update) org.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete) org.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember) org.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember) org.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles) org.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole) org.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) // Relying Party Management (Tenant Context) admin.Post("/tenants/:tenantId/relying-parties", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "grant_dev_permissions"), relyingPartyHandler.Create) admin.Get("/tenants/:tenantId/relying-parties", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view_dev_console"), 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", "edit_config"), 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", requireAnyUser, userHandler.ListUsers) admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV) admin.Post("/users", requireAdmin, userHandler.CreateUser) admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers) admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers) admin.Get("/users/:id", requireAdmin, userHandler.GetUser) admin.Get("/users/:id/rp-history", requireAdmin, userHandler.GetUserRpHistory) 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.Patch("/api-keys/:id", requireSuperAdmin, apiKeyHandler.UpdateApiKey) admin.Post("/api-keys/:id/secret/rotate", requireSuperAdmin, apiKeyHandler.RotateApiKeySecret) admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) // ๊ฐœ๋ฐœ์ž ํฌํ„ธ ๋ผ์šฐํŠธ (RP/Consent ๊ด€๋ฆฌ ๋ฐ IdP ์„ค์ •) dev := api.Group("/dev") dev.Get("/stats", devHandler.GetStats) dev.Get("/my-tenants", devHandler.ListMyTenants) dev.Get("/users", devHandler.SearchUsers) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id/users/:userId/metadata", devHandler.GetRPUserMetadata) dev.Put("/clients/:id/users/:userId/metadata", devHandler.UpsertRPUserMetadata) dev.Get("/clients/:id", devHandler.GetClient) dev.Get("/clients/:id/relations", devHandler.ListClientRelations) dev.Post("/clients/:id/relations", devHandler.AddClientRelation) dev.Delete("/clients/:id/relations", devHandler.RemoveClientRelation) dev.Put("/clients/:id", devHandler.UpdateClient) dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache) dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache) dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret) dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) dev.Delete("/clients/:id", devHandler.DeleteClient) dev.Get("/consents", devHandler.ListConsents) dev.Delete("/consents", devHandler.RevokeConsents) dev.Get("/audit-logs", devHandler.ListAuditLogs) dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily) // [New] Developer Registration Flow dev.Post("/developer-request", devHandler.RequestDeveloperAccess) dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus) dev.Get("/developer-request/status", devHandler.GetDeveloperRequestStatus) dev.Get("/developer-request/list", devHandler.ListDeveloperRequests) dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest) dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest) dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval) // 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]any `json:"data,omitempty"` } var req LogReq if err := c.BodyParser(&req); err != nil { return c.SendStatus(fiber.StatusBadRequest) } if !logger.ShouldAcceptClientLog(appEnv, clientLogDebugFlag, req.Level) { return c.SendStatus(fiber.StatusOK) } level := logger.NormalizeClientLogLevel(req.Level) if level == slog.LevelInfo && logger.ShouldFilterNoisyClientInfo(appEnv, clientLogDebugFlag, req.Message) { return c.SendStatus(fiber.StatusOK) } // Prepare attributes for flattening attrs := []any{ slog.String("source", "client"), } sanitizedData := logger.SanitizeClientLogData(req.Data) for k, v := range sanitizedData { // 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)) } } slog.Log(c.Context(), level, logger.SanitizeClientLogMessage(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) } } func configureWorksmobileClientFromEnv(client *service.WorksmobileHTTPClient) { if client == nil { return } client.BaseURL = strings.TrimSpace(getEnv("WORKS_ADMIN_API_BASE_URL", "")) }