package middleware import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "log/slog" "github.com/gofiber/fiber/v2" ) // RBACConfig defines the configuration for RBAC middleware type RBACConfig struct { AllowedRoles []string AuthHandler AuthProfileProvider KetoService service.KetoService } // AuthProfileProvider는 미들웨어에서 사용자 정보를 조회하기 위한 최소 인터페이스입니다. type AuthProfileProvider interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } func setAuditUserContext(c *fiber.Ctx, profile *domain.UserProfileResponse) { if profile == nil || profile.ID == "" { return } if existingUserID, _ := c.Locals("user_id").(string); existingUserID != "" { return } c.Locals("user_id", profile.ID) } // RequireKetoPermission enforces permissions using Ory Keto (ReBAC) func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler { return func(c *fiber.Ctx) error { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_keto)") } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) setAuditUserContext(c, profile) role := domain.NormalizeRole(profile.Role) // Super Admin bypass if role == domain.RoleSuperAdmin { return c.Next() } // Get object ID from path (e.g., tenant ID) // Fix: For Tenant namespace, prioritize tenantId param if available objectID := "" if namespace == "Tenant" { objectID = c.Params("tenantId") } if objectID == "" { objectID = c.Params("id") } if objectID == "" { slog.Error("RBAC Keto check failed: missing object id", "path", c.Path()) return errorJSON(c, fiber.StatusBadRequest, "missing object id for permission check") } slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) // Set tenant_id for audit logging if namespace is Tenant if namespace == "Tenant" { c.Locals("tenant_id", objectID) } // Check with Keto - add User: prefix to subject allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation) if err != nil { slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !allowed { slog.Warn("Keto permission denied", "userID", profile.ID, "userRole", role, "namespace", namespace, "objectID", objectID, "relation", relation, "X-Test-Role", c.Get("X-Test-Role")) return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID) } return c.Next() } } // RequireRole enforces that the user has one of the allowed roles func RequireRole(config RBACConfig) fiber.Handler { return func(c *fiber.Ctx) error { // Bypass if already authenticated via API Key if c.Locals("apiKeyName") != nil { return c.Next() } profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_role): "+err.Error()) } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) setAuditUserContext(c, profile) userRole := domain.NormalizeRole(profile.Role) // Super Admin always has access if userRole == domain.RoleSuperAdmin { return c.Next() } // Check if user's role is in allowed roles roleAllowed := false for _, role := range config.AllowedRoles { if userRole == domain.NormalizeRole(role) { roleAllowed = true break } } if !roleAllowed { slog.Warn("RBAC access denied", "userID", profile.ID, "userRole", userRole, "allowedRoles", config.AllowedRoles, "path", c.Path(), "X-Test-Role", c.Get("X-Test-Role"), ) return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions") } return c.Next() } } // RequireTenantMatch enforces that a Tenant Admin can only access their own tenant's data func RequireTenantMatch(config RBACConfig) fiber.Handler { return func(c *fiber.Ctx) error { // Bypass if already authenticated via API Key (System-wide for now) if c.Locals("apiKeyName") != nil { return c.Next() } profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_match)") } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) setAuditUserContext(c, profile) userRole := domain.NormalizeRole(profile.Role) // Super Admin bypass if userRole == domain.RoleSuperAdmin { return c.Next() } // Since only Super Admin is maintained for tenant management, others are rejected here return errorJSON(c, fiber.StatusForbidden, "forbidden") } }