Custom Site Map Providers in ASP.NET 2.0

 

Morgan Skinner
Microsoft Corporation

August 2005

Applies to:
   Microsoft ASP.NET 2.0
   SiteMapProvider

Summary: This article presents an overview of the provider model in Microsoft ASP.NET 2.0, and presents a custom implementation of the SiteMapProvider, which is used by controls such as the Breadcrumb and Treeview when rendering a page. (12 printed pages)

Contents

Introduction
Providers in ASP.NET 2.0
SiteMaps in ASP.NET 2.0
Writing a Custom Site Map Provider
Other Fun Stuff
Conclusion

Introduction

When creating a site in the current versions of Microsoft ASP.NET (1.0 and 1.1), the provision of navigation controls was left to the developer—there was no inbuilt support for controls such as a breadcrumb, and one of the most often requested enhancements was to have a tree control in the box. With the 2.0 release of .NET, both of these omissions and many more have been addressed, and the provided scheme is now very easy to extend.

What we now have in ASP.NET 2.0 is a set of controls that attach to their datasource using a provider class—this provider is used to generate the data for the UI, and has been defined in an extensible manner so that a custom implementation of that provider can be defined.

This article presents an overview of the provider model in ASP.NET 2.0, and presents a custom implementation of the SiteMapProvider, which is used by controls such as the Breadcrumb and Treeview when rendering a page.

Providers in ASP.NET 2.0

The provider model consists generally of classes—an abstract provider base class, a concrete implementation of this class, and a helper that delegates calls to the concrete provider. To illustrate this, I'll discuss the Membership classes, which are used, among other things, to create, validate, and list users who can access the system.

A common scenario here is when validating a username and password during login. In pre-Visual Studio 2005 days, you would probably have had to write most of this code yourself. Now this scenario can be implemented with minimal coding—in fact, a complete implementation is available out of the box, so you can get on with coding the site and not have to worry about writing the authentication code at all.

Membership fits into the model as shown in the image below.

Aa479320.sitemap_fig01(en-us,MSDN.10).gif

Figure 1. Class diagram of Membership classes in ASP.NET 2.0

The Membership class exposes methods that can be called from your client code—such as ValidateUser. This call is forwarded to the concrete SqlMembershipProvider class which provides the implementation—it is derived from the abstract MembershipProvider class and so can be replaced with any implementation you require.

The specific membership provider is hooked up to the Membership class by altering the web.config and adding a <membership> section, which will look something like the following.

<membership defaultProvider="ASPNET_Auth">
  <providers>
    <add connectionStringName="ASPNET_Auth"
         applicationName="/TestWeb" description="This is a test database"
         requiresUniqueEmail="false" enablePasswordRetrieval="false"
         enablePasswordReset="true" requiresQuestionAndAnswer="false"
         passwordFormat="Hashed" name="ASPNET_Auth"  
         type="System.Web.Security.SqlMembershipProvider, System.Web, 
               Version=2.0.3500.0, Culture=neutral, 
               PublicKeyToken=b03f5f7f11d50a3a" />
  </providers>
</membership>

This also requires a database connection setting in the web.config, as follows.

<connectionStrings>
  <add name="ASPNET_Auth" 
    connectionString="server=(local);Integrated 
      Security=SSPI;database=ASPNET_Auth" />
</connectionStrings>

You can either add these entries manually, or use the new ASP.NET site configuration tool to add them for you.

One extra feature of providers is that you can optionally define several providers for a given area of functionality—this enables you to store information in separate datasources if appropriate. You would add multiple providers to the <providers> section, and then these can be used by your site as necessary.

Site Maps in ASP.NET 2.0

Navigation of a website is often done using a hierarchical structure, where, for instance, you may have pages defined as follows.

Home.aspx
  Articles.aspx
    Article1.aspx
    ...
    ArticleN.aspx
  Tips.aspx
    Tip1.aspx
    ...
    TipN.aspx

For a simple website, this structure can be stored in the static web.sitemap file, which can then be used by creating a SiteMapDataSource control on the Web page, and binding that to a TreeView control or the SiteMapPath control which shows where you are in the navigation hierarchy.

The web.sitemap for the above pages would be as follows.

<?xml version="1.0" encoding="utf-8" ?>
<siteMap>
  <siteMapNode url="Home.aspx" title="Home">
    <siteMapNode url="Articles.aspx" title="Articles" >
      <siteMapNode url="Article1.aspx" title="Article01" />
      ...
      <siteMapNode url="ArticleN.aspx" title="ArticleN" />
    </siteMapNode>
    <siteMapNode url="Tips.aspx" title="Tips" >
      <siteMapNode url="Tip1.aspx" title="Tip01" />
      ...
      <siteMapNode url="TipN.aspx" title="TipN" />
    </siteMapNode>
  </siteMapNode>
</siteMap>

Using the out-of-the-box functionality, all you need is this site map file, a SiteMapDataSource, and the one or more controls that use this data source, and you're all set. That's not where the fun ends with site maps; otherwise, this could be a very short article.

There are a number of attributes that can be applied to nodes in the site map file—most commonly used are description and roles. The former allows you to create descriptive text for the content of a given page, whereas the latter permits you to define the visibility of a node (or group of nodes), based on the role of the current user.

Using roles on a site map node permits you to create one site map resource that is used by all users. Some nodes will show up for all users, whether authenticated or not, whereas others may be dependent on roles assigned to the user as they login. Just setting roles within the site map isn't enough to enable this feature, however—there are several other steps that are necessary.

The ASP.NET team has endeavoured to make using site maps (and other providers) as simple as possible and, to that end, to construct a general purpose file-based site map provider within the global web.config file available in the CONFIG directory. This will suffice for most simple sites; however, this definition does not include the securityTrimmingEnabled attribute, which is needed in order for the site map to only display nodes that a given user can access based on his or her role.

In order to ensure that the site map only displays appropriate nodes to the user based on his or her role, you need to add another provider entry to the site map <providers> section in the web.config for your application, and define this attribute there. The configuration section shown below defines a provider which includes this attribute. It also removes the AspNetXmlSiteMapProvider from the providers collection, and defines the default provider as SecuredSiteMapProvider. Removing the standard provider isn't strictly necessary; however, I've done it here because, in that way, none of the pages in the site can inadvertently use the generic provider.

<siteMap enabled="true" defaultProvider="SecuredSiteMapProvider">
  <providers>
    <clear/>
    <add name="SecuredSiteMapProvider" 
      type="System.Web.XmlSiteMapProvider, System.Web, 
      Version=2.0.3600.0, Culture=neutral, 
      PublicKeyToken=b03f5f7f11d50a3a" 
      siteMapFile="web.sitemap" securityTrimmingEnabled="true"/>
    <add name="AtriclesProvider" 
       type="System.Web.XmlSiteMapProvider, System.Web, 
       Version=2.0.3600.0, Culture=neutral, 
       PublicKeyToken=b03f5f7f11d50a3a" 
       siteMapFile="Articles/web.sitemap" securityTrimmingEnabled="true"/>
    </providers>
</siteMap>
<roleManager enabled="true" />

Once the <siteMap section> is defined, you also need to enable the role provider—this is shown as the last line in the preceding figure. Enabling the role provider ensures that you can issue checks such as IsInRole("Administrator") within your code. The RoleManager does this by injecting the RoleManagerModule into your application, which is used to set up an object that implements the IPrincipal interface. Before Visual Studio 2005, you would have to have written all of this code yourself. Now, it's just a simple addition to the web.config file.

With the RoleManager set up, and the appropriate site map provider in the web.config, you're nearly ready to provide site maps tailored to the current user. There are two more steps that are necessary, however. The first is to alter the site map file to list the role(s) that can access a particular page or section of the website. This involves modifying the web.sitemap file and adding a roles attribute to any nodes that need to be secured.

<siteMapNode url="Editor/Editor.aspx" title="Editor" roles="Editor" />

The last part of the puzzle is altering in the web.config and specifying roles for particular pages (or directories). When a node is requested from the site map, the SiteMapNode.IsAccessibleToUser() method will be called to ensure that the user has access to that part of the site map. This is done by checking whether the requested page/folder is accessible. If so, the node can be displayed; if not, it is excluded from the site map. In my example, I have created an Editor directory, and specified that this can only be accessed by users with the role Editor. I made this modification in the top-level web.config, rather than defining a separate web.config within the Editor subdirectory.

<location path="Editor" allowOverride="false">
  <system.web>
    <authorization>
      <allow roles="Editor"/>
      <deny users="*,?"/>
    </authorization>
  </system.web>
</location>

Defining access permissions in the top-level web.config is good practice—especially on larger sites where many teams may provide content for various directories. Doing this in one place (and not permitting it to be inadvertently overridden, by specifying allowOverride=false) means that you can be sure that users only see what you want them to see.

Here's a list of the steps needed to set up a site map that is tailored to suit the current user:

  • Add a new site map provider to the web.config, and ensure the securityTrimmingEnabled flag is set to true.
  • Enable the RoleManager by altering the web.config.
  • Define access permissions to pages/directories in the web.config.
  • Specify roles on site map nodes that are not accessible to all users.

In addition to defining security for nodes in a site map, you may wish to create multiple site map files—you may have one main site map which defines the overall structure of the site, and secondary files that define specific portions of that site. One example of where this would be useful is in a site that has areas maintained by different teams. As a team updates its portion of the site, it can alter the sitemap that corresponds to its area, without tampering with any other parts of the site.

In order to accomplish this, you need to do two things. First, it is necessary to define a number of providers within the site map provider section of the configuration file. When defining a provider, you can optionally include the site map filename—here, you can supply different filenames (or paths) which map to specific areas of the site. An example of this is shown in the <siteMap> section above, where I have created two site map providers—SecuredSiteMapProvider and ArticlesProvider. The latter of these can then be user to link to a different site map—in this case, within a separate subdirectory of the site.

Then, in the main site map file, you include a node that links to this secondary map, as follows.

<?xml version="1.0" encoding="utf-8" ?>
<siteMap>
  <siteMapNode url="Home.aspx" title="Home">
    <siteMapNode provider="ArticlesProvider" />
  </siteMapNode>
<siteMap>

The provider of this secondary site map could be a standard XML file, or, as is the case in this article, I'll show you how you can create a custom site map provider that will read its configuration information from the database. This scenario is common in larger scale websites, especially where content is generated dynamically. In the example code, I'll show how a website can be set up to construct a dynamic site map based on records from a database. As new records are added to the database, this is reflected in the site map.

Writing a Custom Site Map Provider

It seems to have taken a long time to get here, but we're now ready to create a custom site map provider which can be integrated into the application. There are several classes that make up the site map hierarchy—these are shown in the figure below.

Aa479320.sitemap_fig02(en-us,MSDN.10).gif

Figure 2. Classes in the site map hierarchy

The ProviderBase class is the base class for all providers in .NET 2.0, and exposes the Initialize method and two properties—Name and Description. In order for .NET to be able to access a given provider, it uses the type attribute to dynamically construct an instance of the class. Then, it calls the Initialize() method and passes through the other attributes defined in the configuration section.

Aa479320.sitemap_fig03(en-us,MSDN.10).gif

Figure 3. Relationship of provider with sitemap.config file

ProviderBase uses the mandatory name attribute and stores this away internally, and also reads the optional description attribute. Any other attributes you wish can be defined within the configuration file—which is how, for example, the XmlSiteMapProvider is fed the name of the site map file.

When I'm working with a new set of classes, I often need to find out the order in which functions and properties are called (or whether they are called at all!). It often helps to get an understanding of how the classes are used, in order to write your own. The SiteMapProvider was no exception in this instance. What I do is get a class up and running with the minimal amount of coding, and then flesh it out later if I need to add extra functionality. I regularly use Lutz Roeders Reflector to spelunk the inner workings of a set of classes; however, it's difficult to work out exactly the order in which everything is called, without a working example.

In this case, I constructed two classes—one derived from SiteMapProvider, and the other derived from SiteMapNode—and implemented each abstract or virtual function and property to log the call and parameters to the current response stream. An example of one of the properties from SiteMapProvider together with the logging code is shown below.

public override SiteMapNode RootNode
{
  get
  {
    Log("get_RootNode");
    return new MySiteMapNode(this, "Key", "default.aspx", "Root");
  }
}

[Conditional("VERBOSE")]
private static void Log(string caption, params object[] args)
{
    string s = string.Format(caption, args);

    HttpContext.Current.Trace.Write( 
      string.Format("MySiteMap::{0}<br/>", s));
}

Having a full ordered list of which functions are called, I can then start implementing features up to the point where I have a functional example. Note that in the implementation of the Log function above, I have used the Conditional attribute to ensure that this function, and all calls to it, are not present in release builds. If you do the same, I'd suggest using a constant other than DEBUG—something like VERBOSE would be worthwhile—because in that way, you can still leave the log statements in the source code, but not be bothered unduly by them unless you really want to use them.

One slight problem with using conditional compilation constants is that, in Web projects, you cannot easily define your own—you either have to define the constant on the page by using <%@ Page ... CompilerOptions="/d:DEBUG" %>, or by altering the web.config and adding a <compilers> section similar to that in the global web.config. If you add the constant to the page, it only applies to code within that page; therefore, to define an application global compilation constant, I would recommend defining it within the web.config as shown below.

<compilation defaultLanguage="cs" debug="true">
  <compilers>
    <compiler language="C#;csharp;cs"
       extension=".cs;cs" 
       type="Microsoft.CSharp.CSharpCodeProvider, 
         System, Version=2.0.0.0, Culture=neutral, 
         PublicKeyToken=b77a5c561934e089"
       compilerOptions="/define:VERBOSE" />
  </compilers>
</compilation>

With the above defined, I can then see the order in which methods on my custom provider are called.

The first method called on the custom provider is Initialize(). This is passed all of the attributes defined within the web.config file, other than the type of the provider. You can therefore add extra processing capabilities to your provider by supporting other non-standard attributes, an example of which I'll show later in this article.

After the Initialize() method, the next thing called is the GetRootNodeCore method. From this, you need to return an instance of a SiteMapNode or a derived class. This will be used as the top item in your hierarchy. Implementing a derived class is very simple, because all of the functionality you'll need is provided by this base class. In my example, I have created a SqlSiteMapNode; however, it's merely a derivation of the base class and doesn't add any extra capabilities.

Once the root node has been returned, ASP.NET then calls the setter method of the ParentProvider property. In this case the parent provider is the XmlSiteMapProvider, because my custom site map is included as a node within the main XML site map file. You need only override this property if you wish to provide some extra functionality here.

Each sitemap node has Title, Description, and URL properties, and these are read next, in order to provide an appropriate display to the user. One reason to override the Title is to provide the capability to localize the text based on the locale of the currently logged in user. Partial support for localization is provided in the SiteMapProvider class, because it defines a Boolean EnableLocalization property which can be set by your provider. The XmlSiteMapProvider uses an enableLocalization attribute (which is read from the site map file in the BuildSitemap function), so, for consistency, you should use an attribute with the same name if you wish to support this localization.

Prior to displaying a node, a call is made to the IsAccessibleToUser property. This is used in the case of an XmlSiteMapNode to verify that the current user can access the particular URL that is defined for a node. Supporting this method is easy—as the base class all of the work is done for you, and unless you have some specific behaviour that is not catered to by the current implementation, you won't need to override this method.

One quirk of the implementation of IsAccessibleToUser is the way the current implementation works. If you define a list of roles on a node, you could reasonably assume that if a given user was not in that role, then they would not see that node. You can try this yourself by creating a simple XML site map, setting a role on one of the nodes to one you know the current user will not possess (my usual one here is sausage), and then display the site map. You'll see that rather than being hidden, the node will actually show up.

This comes about because the code in SiteMapProvider.IsAccessibleToUser doesn't immediately bail out if the role isn't one the user has been granted access to. Instead, the code then checks URL permissions, to verify whether the user can (or cannot) access the given page. This means that, despite there being roles defined alongside the site map node, you'll also need to define an <authorization> section within the web.config. In my implementation, I have decided to short circuit this processing and immediately return false from IsAccessibleToUser if the roles defined for a node are not possessed by the current user.

This ensures that nodes won't show up in the site map for pages you don't wish certain users to access; however, they could still type in a URL and attempt to gain access to that page. In this scenario, you'll still need to define URL-based permissions within the web.config, or choose another scheme—such as verifying roles in the page code and redirecting the user if they don't have sufficient access privileges for that page.

The methods/properties I've implemented on my SqlSiteMapProvider are as follows. For full details of the implementation please see the article source code.

Table 1. Method/Properties on SqlSiteMapProvider

Method/Property Description
Initialize Passed all attributes from the <providers> section of web.config. Here I call the base class Initialize() method, and then read any custom attributes defined and store these away within the provider.
FindSiteMapNode Given the passed URL, returns a site map node that corresponds to this URL.
GetChildNodes Given a passed node, queries the database to find any child nodes. As an optimization, this checks the SiteMapNodes' HasChildNodes property, which is set to False for any nodes that are leaf nodes.
GetParentNode For a given node, returns the parent node.
GetRootNodeCore Called by the RootNode property to retrieve the topmost node in the site map.
IsAccessibleToUser Checks whether a given node can be accessed by the current user.

As you can see, there's not too much to implement, but there are a few more things worth going through. I've created the following database structure to hold appropriate information about nodes and the hierarchy of these nodes.

Aa479320.sitemap_fig04(en-us,MSDN.10).gif

Figure 4. Nodes table

In addition to this table, I created a set of stored procedures that would select nodes from this table for use by the site map provider. I then created two different SQL-based providers—one which doesn't cache anything and goes back to the database for every request, and another that caches the entire site map in memory in one hit.

The table is hierarchical in nature, so I've used a Common Table Expression (CTE) to retrieve the entire hierarchy of data in one roundtrip. CTEs are only available in SQL Server 2005.

CREATE PROCEDURE SelectTreeNodes AS
  set nocount on ;
  
  WITH NodeTree ( NodeID, Caption, URL, ParentNodeID, Roles) AS 
  ( 
    SELECT NodeID, Caption, URL, ParentNodeID, Roles
      FROM Node
      WHERE ( ParentNodeID IS NULL )
    UNION ALL
      SELECT child.NodeID, child.Caption, child.URL, 
          child.ParentNodeID, child.Roles
        FROM Node AS child INNER JOIN
          NodeTree AS parent ON parent.NodeID = child.ParentNodeID
  )
  SELECT NodeID, Caption, URL, ParentNodeID, Roles
    FROM NodeTree 

The CTE here is the WITH NodeTree expression—this generates a result set which is returned to the provider and parsed to generate all the nodes in the site map. I haven't explicitly ordered the result set here, so it comes back in an appropriate hierarchical order, with the parent row first, then all first-level children, then any second-level children, and so on.

To use the CTE-based provider in the example code, comment out the TipsProvider and uncomment the CTEProvider, as shown below.

<?xml version="1.0" encoding="utf-8" ?>
<siteMap xmlns="https://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
  <siteMapNode url="default.aspx" title="Home" description="">
    <siteMapNode provider="ArticlesProvider" />
    <!--siteMapNode provider="TipsProvider" /-->
    <siteMapNode provider="CTEProvider" />
    <siteMapNode url="Editor/Editor.aspx" title="Editor" roles="Editor" />
  </siteMapNode>
</siteMap>

Other Fun Stuff

Earlier in the article, I discussed what happens in the Initialize() method of your provider. To recap, this is passed all attributes (other than the type attribute) defined within the web.config. Any custom XML attributes you have defined within the provider configuration line will be passed into your provider. This gives you a way to alter the behaviour of the provider, thereby making it suit more potential customers.

As an example, the standard providers have the securityTrimmingEnabled attribute, which defines whether the roles defined for a node are evaluated before displaying that node. Support for this is already built in to the SiteMapProvider class, so instead, I'll define an attribute which permits you to choose the node in the database where the site map will begin.

The table consists of a number of node records in a hierarchical structure. By default, the provider selects all nodes from the top down, and displays these to the user. With a small addition, we can make this start from a particular node in the hierarchy—all that is needed is to parse the incoming attributes collection and look for any custom XML attributes you have defined. In the example, I've added code to parse the startFrom attribute. This defines the numeric ID of a node within the database, and if used, the site map will use this as the root node.

Having spent the entire article discussing providers, I'll finish up with showing how you can change the behaviour of the TreeView when displaying a large site map. When a TreeView control is bound to a hierarchical datasource such as the SiteMapDataSource, you can define when nodes are retrieved. The default is to select all nodes in the hierarchy and return these to the caller; however, with a large site, you may wish to only build the site map on demand. Consider the MSDN site: if it downloaded the entire treeview at runtime, not many people would use the site. What's needed is a way to demand-load nodes in the tree.

To do this, you need to add a <DataBindings> section to the TreeView, and then add an <asp:TreeNodeBinding> element, as shown below.

<asp:TreeView ID="siteHierarchy" runat="server" 
  DataSourceID="MainSiteMap" ExpandDepth=1>
  <DataBindings>  
    <asp:TreeNodeBinding PopulateOnDemand=true 
     TextField="Title" NavigateUrlField="URL" />
  </DataBindings>
</asp:TreeView>

Here, I've defined the ExpandDepth attribute to be 1, which ensures that the root level and one level down are displayed by default. In the <asp:TreeNodeBinding> element, I've defined which fields are used for the text of the node and the URL. The important attribute is PopulateOnDemand. With this set to true, ASP.NET generates Javascript code which is executed when a node is expanded. This calls back to the server, retrieves any nodes beneath the current selected node, and displays these new nodes to the client.

Conclusion

The provider model in ASP.NET 2.0 is very powerful, because it permits standard functionality to be replaced with custom components, while still providing a simple interface for consumers. One of the main benefits is that you can replace back-end components such as Membership, Role management, and the SiteMap, without needing to change the user interface. In this article, I've shown how to create a custom site map provider and hook this into an ASP.NET site.

The replaceable nature of the provider model doesn't need to be used just with ASP.NET 2.0—you can easily copy the idea and use it now in .NET 1.x. You might also find that your upgrade path to ASP.NET 2.0 is simpler if you take this approach.

 

About the author

Morgan is an Application Development Consultant working for Microsoft in the UK. He specializes in C#, ASP.NET and Windows Forms, and has been working with .NET since the PDC release in 2000. His home page is https://www.morganskinner.com.

© Microsoft Corporation. All rights reserved.