forked from baron/baron-sso
Changes the /users endpoint to allow RoleUser access and securely restricts the returned data to only users within their affiliated tenants. Removes the unnecessary back button from the Org Chart view since it's now a top-level nav item.
756 lines
29 KiB
Go
756 lines
29 KiB
Go
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"
|
|
"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 shouldEnableDocs(appEnv string) bool {
|
|
return !logger.IsProductionLikeEnv(appEnv)
|
|
}
|
|
|
|
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
|
|
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] 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)
|
|
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
|
kratosAdminService := service.NewKratosAdminService()
|
|
oryAdminProvider := service.NewOryProvider()
|
|
|
|
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
|
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)
|
|
|
|
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, tenantService, authHandler)
|
|
devHandler.HeadlessJWKS = headlessJWKSCache
|
|
devHandler.AuditRepo = auditRepo
|
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
|
|
|
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
|
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
|
|
|
|
// 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.Split(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,
|
|
})
|
|
})
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
|
|
// Tenant Management (Mixed roles, handler filters results)
|
|
admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants)
|
|
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
|
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)
|
|
|
|
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
|
org := admin.Group("/tenants/:tenantId/organization")
|
|
org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API
|
|
org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API
|
|
|
|
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", "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", requireAnyUser, userHandler.ListUsers)
|
|
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param 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.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("/clients", devHandler.ListClients)
|
|
dev.Post("/clients", devHandler.CreateClient)
|
|
dev.Get("/clients/:id", devHandler.GetClient)
|
|
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)
|
|
|
|
// 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)
|
|
}
|
|
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)
|
|
}
|
|
}
|