Monday, 19 April 2010

Elevating Ektron User Permissions Safely

When you’re coding against the Ektron API you frequently find yourself needing to add/modify content as a result of a user action or similar privileged tasks.  To do this you need to impersonate a more privileged user (such as  InternalAdmin) for the duration of the task and then revert to the current users privileges.

The approach most frequently quoted on the Ektron dev forums is along the lines of:

   1: public void DoElevatedPermission()
   2: {
   3:    int currentCallerId;
   4:    int currentUserId;
   5:  
   6:    Ektron.Cms.CommonApi capi = new Ektron.Cms.CommonApi();
   7:    currentCallerId = cAPI.RequestInformationRef.CallerId;
   8:    currentUserId = cAPI.RequestInformationRef.UserId;
   9:  
  10:    // impersonate InternalAdmin
  11:    capi.RequestInformationRef.CallerId = Ektron.Cms.Common.EkConstants.InternalAdmin;
  12:    capi.RequestInformationRef.UserId = Ektron.Cms.Common.EkConstants.InternalAdmin;
  13:  
  14:    // Do work that requires elevated/impersonated permissions
  15:  
  16:    //set back to current user
  17:    capi.RequestInformationRef.CallerId = currentCallerId;
  18:    capi.RequestInformationRef.UserId = currentUserId;
  19: }

This works fine (as long as nothing goes wrong) but if an exception is thrown whilst performing the elevated method then there’s a risk that the rest of the request will run using the InternalAdmin permissions.  This could cause havoc!

So a smart approach would be:

   1: public void DoElevatedPermission()
   2:     {
   3:         int currentCallerId;
   4:         int currentUserId;
   5:         Ektron.Cms.CommonApi capi = new Ektron.Cms.CommonApi();
   6:         try
   7:         {
   8:             currentCallerId = cAPI.RequestInformationRef.CallerId;
   9:             currentUserId = cAPI.RequestInformationRef.UserId;
  10:  
  11:             //set impersonate InternalAdmin
  12:             capi.RequestInformationRef.CallerId = Ektron.Cms.Common.EkConstants.InternalAdmin;
  13:             capi.RequestInformationRef.UserId = Ektron.Cms.Common.EkConstants.InternalAdmin;
  14:  
  15:             // Do work that requires elevated/impersonated permissions
  16:         }
  17:         finally
  18:         {
  19:             //set back to current user
  20:             capi.RequestInformationRef.CallerId = currentCallerId;
  21:             capi.RequestInformationRef.UserId = currentUserId;
  22:         }
  23:     }

This guarantees that the real user is restored in any eventuality (where the request would be able to continue processing – clearly a finally block won’t protect your code from a meteor strike!).  But that’s a lot of boiler place code wrapping a single line representing work.  When you actually start doing work it’s going to get very complicated, very quickly!

Through a little IDisposable abuse it’s possible to replace a lot of this boiler plate code with a using statement, so the above code can be neatened up into something like this:

   1: public void DoElevatedPermission()
   2: {
   3:    Ektron.Cms.CommonApi contentApi = new Ektron.Cms.CommonApi();
   4:    using (ElevatedPermissionScope adminScope = new ElevatedPermissionScope(contentApi))
   5:    {
   6:        //Perform Elevated Tasks Here
   7:    }
   8:    // normal permissions have been restored
   9: }

Much neater.

Here’s the ElevatedPermissionScope implementation:

   1: using System;
   2: using Ektron.Cms;            // from Ektron.Cms.Common assembly
   3: using Ektron.Cms.Common;     // from Ektron.Cms.Common assembly
   4:  
   5: namespace MartinOnDotNet.Ektron.Security
   6: {
   7:  
   8:     /// <summary>
   9:     /// Utility class used to wrap a set of operations that must
  10:     /// be done within a more elevated security context than the current
  11:     /// user.
  12:     /// </summary>
  13:     /// <remarks>
  14:     /// <para>Due to implementation of the Ektron API each API implementation
  15:     /// must have it's own elevated scope wrapper.</para>
  16:     /// <para>Example of usage:</para>
  17:     /// <code>
  18:     /// ContentAPI contentApi = ApiFactory.Create&lt;ContentAPI&gt;();
  19:     /// using (ElevatedPermissionScope adminScope = new ElevatedPermissionScope(contentApi))
  20:     /// {
  21:     ///     //Perform Elevated Tasks Here
  22:     ///        
  23:     /// }
  24:     /// // perform normal tasks here
  25:     /// </code>
  26:     /// <para>
  27:     /// As this class manipulates <see ref="CommonApi" /> objects it can only
  28:     /// be used when there is a populated <see ref="System.Web.HttpContext" /> available.
  29:     /// </para>
  30:     /// </remarks>
  31:     public sealed class ElevatedPermissionScope : IDisposable
  32:     {
  33:         // Required Imports:
  34:         // using Ektron.Cms;            // from Ektron.Cms.Common assembly
  35:         // using Ektron.Cms.Common;     // from Ektron.Cms.Common assembly
  36:  
  37:  
  38:         /// <summary>
  39:         /// Initializes a new instance of the <see cref="ElevatedPermissionScope"/> class and configures
  40:         /// the latent userId to be the Ektron InternalAdmin user
  41:         /// </summary>
  42:         /// <param name="api">The API to elevate</param>
  43:         public ElevatedPermissionScope(CommonApi api)
  44:             : this(api, EkConstants.InternalAdmin, EkConstants.InternalAdmin)
  45:         { }
  46:  
  47:  
  48:         /// <summary>
  49:         /// Initializes a new instance of the <see cref="ElevatedPermissionScope"/> class and configures
  50:         /// the latent userId to the provided values
  51:         /// </summary>
  52:         /// <param name="api">The API to elevate</param>
  53:         /// <param name="callerId">The caller id to impersonate</param>
  54:         /// <param name="userId">The user id to impersonate</param>
  55:         public ElevatedPermissionScope(CommonApi api, long callerId, long userId)
  56:             : this(api.RequestInformationRef, callerId, userId)
  57:         { }
  58:  
  59:         /// <summary>
  60:         /// Initializes a new instance of the <see cref="ElevatedPermissionScope"/> class.
  61:         /// </summary>
  62:         /// <param name="requestInfo">The request info to configure</param>
  63:         /// <param name="callerId">The caller id.</param>
  64:         /// <param name="userId">The user id.</param>
  65:         public ElevatedPermissionScope(EkRequestInformation requestInfo, long callerId, long userId)
  66:         {
  67:  
  68:             if (requestInfo == null) throw new ArgumentNullException("requestInfo");
  69:             RequestInfo = requestInfo;
  70:             OriginalCallerId = RequestInfo.CallerId;
  71:             OriginalUserId = RequestInfo.UserId;
  72:             RequestInfo.CallerId = callerId;
  73:             RequestInfo.UserId = userId;
  74:             RequestInfo.UniqueId = 0;
  75:         }
  76:  
  77:         #region Internal Properties
  78:  
  79:         /// <summary>
  80:         /// Gets or sets the request info.
  81:         /// </summary>
  82:         /// <value>The request info.</value>
  83:         internal EkRequestInformation RequestInfo { get; set; }
  84:  
  85:         /// <summary>
  86:         /// Gets or sets the original caller id.
  87:         /// </summary>
  88:         /// <value>The original caller id.</value>
  89:         internal long OriginalCallerId { get; set; }
  90:  
  91:         /// <summary>
  92:         /// Gets or sets the original user id.
  93:         /// </summary>
  94:         /// <value>The original user id.</value>
  95:         internal long OriginalUserId { get; set; }
  96:  
  97:         #endregion
  98:  
  99:         #region IDisposable Members
 100:  
 101:         /// <summary>
 102:         /// Restores the original Latent User Id
 103:         /// </summary>
 104:         public void Dispose()
 105:         {
 106:             RequestInfo.CallerId = OriginalCallerId;
 107:             RequestInfo.UserId = OriginalUserId;
 108:             GC.SuppressFinalize(this);
 109:         }
 110:  
 111:         #endregion
 112:     }
 113:  
 114: }

Friday, 16 April 2010

Enabling Concurrent Logins in Ektron

For one of our major clients we needed a semi-secure area – you needed credentials to view the content within, but there was only one set of credentials.   This meant that multiple users could access the site concurrently with the same credentials and this is a bit of a problem for the Ektron security model as it’s based around a one session per login model.

This model is enforced by a unique integer that is generated on login and persisted in the database (in [dbo].[users].[login_identification] schema fans) with the user account and also stored within a cookie (‘ecm’) that is returned to the client.  When an authenticated request comes in, Ektron checks the unique id in the cookie matches the value in the database and if not, your permission checks fail (but doesn’t log you out – irritatingly you can be browse the site in a semi-authenticated state!)

The Fix

To get around this we decided to fool the Ektron API into thinking that everyone using the credentials are in fact the same user by hijacking the ‘ecm’ cookie and updating the unique id for the provided user id using a HttpModule (class definition and event wiring omitted for brevity) :

   1: private const string EcmCookie = "ecm";
   2: private const string IsMembershipUserKey = "isMembershipUser";
   3: private const string UserIdKey = "user_id";
   4: private const string UsernameKey = "username";
   5: private const string UniqueIdKey = "unique_id";
   6: private const string SiteIdKey = "site_id";
   7:  
   8: /// <summary>
   9: /// Called at the begining of the request
  10: /// </summary>
  11: /// <param name="sender">The sender.</param>
  12: /// <param name="e">The <see cref="System.EventArgs"/> instance containing the event data.</param>
  13: private void OnBeginRequest(object sender, EventArgs e)
  14: {
  15:     HttpContext current = HttpContext.Current;
  16:     if (current == null || current.Request == null) return;
  17:     if (current.Request.Cookies.AllKeys.Contains(EcmCookie, StringComparer.OrdinalIgnoreCase))
  18:     {
  19:         HttpCookie ecm = current.Request.Cookies[EcmCookie];
  20:         if (ecm[IsMembershipUserKey].ToInt(0) != 1) return;
  21:         int userId = ecm[UserIdKey].ToInt(0); // uses extension method from http://bit.ly/bAZrMI
  22:         string username = ecm[UsernameKey];
  23:         if (userId > 0 && !string.IsNullOrEmpty(username))
  24:         {
  25:             string loginId = GetLoginId(userId, username); //get unique id
  26:             if (!string.IsNullOrEmpty(loginId))
  27:             {
  28:                 ecm[UniqueIdKey] = loginId;
  29:                 string[] siteId = ecm[SiteIdKey].Split(',');
  30:                 ecm[SiteIdKey] = string.Concat(siteId[0], ",", loginId);
  31:                 current.Response.Cookies.Add(ecm);
  32:             }
  33:         }
  34:     }
  35: }
  36:  
  37: private static string GetLoginId(int userId, string username)
  38: {
  39:     using (SqlConnection conn = new SqlConnection(EktronConnectionString))
  40:     {
  41:         using (SqlCommand cmd = conn.CreateCommand())
  42:         {
  43:             cmd.CommandText = LoginQuery;
  44:             cmd.Parameters.AddWithValue(UserIdKey, userId);
  45:             cmd.Parameters.AddWithValue(UsernameKey, username);
  46:             cmd.Connection.Open();
  47:             object result = cmd.ExecuteScalar();
  48:             if (result == null || result == DBNull.Value) return string.Empty;
  49:             return result.ToString();
  50:  
  51:         }
  52:     }
  53: }
  54:  
  55: private const string LoginQuery = "SELECT [login_identification] FROM [dbo].[users] WHERE [user_id] = @user_id AND [user_name] = @username";
  56:  
  57: private static string EktronConnectionString
  58: {
  59:     get
  60:     {
  61:         global::Ektron.Cms.CommonApi capi = new Ektron.Cms.CommonApi();
  62:         return capi.RequestInformationRef.ConnectionString;
  63:     }
  64: }

This approach doesn’t impact the level of security offered by Ektron as the secondary ASP.NET Authorization cookie is unaffected and all of these values are in the ‘public domain’ as they’re sent – unencrypted- to the client as a cookie.

The Optimization

Whilst the above approach worked, it’s not particularly scalable as it adds additional database traffic to every authenticated request.  As this was expected to be a high-usage feature we need to balance keeping track of the latest login id with keeping additional database queries to a minimum.

To do this we’ve taken advantage of the ASP.NET 2.0/SQL Server 2005 SqlCacheDependency (in System.Web.Caching namespace) feature to allow us to cache the latest login id per user and have it evicted whenever it changes (when someone new logs in). 

Fortunately, this is fairly simple to.

Firstly, we needed to prepare the database for QUERY NOTIFICATIONS:

   1: USE master
   2: GO
   3: ALTER DATABASE [DatabaseName] SET ENABLE_BROKER WITH ROLLBACK IMMEDIATE;
   4: GO
   5: USE [DatabaseName] 
   6: GO
   7: GRANT CREATE PROCEDURE to [EktronDatabaseUser] 
   8: GRANT CREATE QUEUE to [EktronDatabaseUser]
   9: GRANT CREATE SERVICE to [EktronDatabaseUser]
  10: GRANT REFERENCES on
  11: CONTRACT::[http://schemas.microsoft.com/SQL/Notifications/PostQueryNotification] to [EktronDatabaseUser]
  12: GRANT VIEW DEFINITION TO [EktronDatabaseUser]
  13: GRANT SELECT to [EktronDatabaseUser]
  14: GRANT SUBSCRIBE QUERY NOTIFICATIONS TO [EktronDatabaseUser]
  15: GRANT RECEIVE ON QueryNotificationErrorsQueue TO [EktronDatabaseUser]
  16: GRANT REFERENCES on
  17: CONTRACT::[http://schemas.microsoft.com/SQL/Notifications/PostQueryNotification] to [EktronDatabaseUser]
  18: GO

Next, we need to ensure the SqlDependency.Start method is called using the Ektron.DbConnection connectionstring.  This is best done in the Global.asax Application_Start event:

   1: protected void Application_Start(object sender, EventArgs e)
   2: {
   3:    SqlDependency.Start(new global::Ektron.Cms.CommonApi().RequestInformationRef.ConnectionString);
   4: }
   5:  
   6: protected void Application_End(object sender, EventArgs e)
   7: {
   8:    SqlDependency.Stop(new global::Ektron.Cms.CommonApi().RequestInformationRef.ConnectionString);
   9: }

We could then amend our ‘GetLoginId’ method to implement the caching:

   1: private static string GetLoginId(int userId, string username)
   2: {
   3:     string cacheKey = string.Format(CultureInfo.InvariantCulture, "LoginId_{0}_{1}", userId, username);
   4:     string cachedValue = null;
   5:     if (HttpContext.Current !=null) cachedValue = HttpContext.Current.Cache[cacheKey] as string;
   6:     if (!string.IsNullOrEmpty(cachedValue)) return cachedValue;
   7:     using (SqlConnection conn = new SqlConnection(EktronConnectionString))
   8:     {
   9:         using (SqlCommand cmd = conn.CreateCommand())
  10:         {
  11:             cmd.CommandText = LoginQuery;
  12:             cmd.Parameters.AddWithValue(UserIdKey, userId);
  13:             cmd.Parameters.AddWithValue(UsernameKey, username);
  14:             SqlCacheDependency dependency = new SqlCacheDependency(cmd);
  15:             cmd.Connection.Open();
  16:             object result = cmd.ExecuteScalar();
  17:             if (result == null || result == DBNull.Value) return string.Empty;
  18:             if (HttpContext.Current !=null)
  19:             {
  20:                 HttpContext.Current.Cache.Insert(cacheKey, result.ToString(), dependency);
  21:             }
  22:             return result.ToString();
  23:  
  24:         }
  25:     }
  26: }

Job’s a good ‘un. (Until Ektron change the membership user login mechanism

Preventing Account Lockouts

It may be worth disabling the Login attempts feature of Ektron to prevent the shared accounts from being locked.  Simply set the ek_loginAttemps value to –1 in appSettings:

   1: <add key="ek_loginAttempts" value="-1" />