Salient Solutions

wrasslin ones and nones for fun and profit - Sky Sanders' Blog
Get your own ranked flair here
posts - 88, comments - 54, trackbacks - 0

Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

 

After a false start, a running battle in coments and a hot steaming serving of crow, all in response to a question on StackOverflow regarding a scenario in which I think the OP could use a single SqlMembershipProvider with multiple SqlRoleProviders and SqlProfileProviders, I believe I have a solution that is not a 'hack' or 'subverts' the intended usage of the SqlProvider stack.

Lets begin with a bit of shadetree philosphy:

If you can re-use something that has had millions of dollars and thousands of man hours devoted to design, coding, documentation and test, you should do so.

Implementing skeleton custom providers  can be accomplished by implementing just a few methods but then there are the schema changes to contend with.

If you can get the whole stack for free with a bit of creative thinking, maybe that is the way to go.

Less work, more play.

Ok, so that is my mindset going into this saga.....

My first solutions was simple and worked with the exception of user deletion, which I was unwilling to acknowledge. Thomas insisted this was the weak spot and it was, although not, I think, in the way he supposed it was. But as stubborn as I am, I argued for a few days, nitpicking on some of the things I thought he was misunderstanding and all the while I was missing the big picture.

Here is is in a nutshell:

  • A common database and connection string,
  • A common membership application name,
  • A common machineKey section so that each site will use the common forms ticket.
  • A UNIQUE role provider application name.

It looked good to me, I built a solution real quick and was off to the races. I was quite suprised when it was shot down and actually called a hack. Who you callin a hack?

Well, another detractor, Gabriel, eventually quit arguing with me but Thomas stuck it out, although we were talking apples and oranges at each other for a couple days.

It wasn't until I after I threw up my hands and pointed him @ Strong Opinions, Weakly Held that I stopped for a minute and realized maybe I needed to re-read it as well.

So, I took a step back, put together some tools and actually ran quite a few tests it became clear that Thomas was right, inadvertantly but right nonetheless, in stating it was not viable out of the box.

The problem lay in that when SqlMembershipProvider deletes a user, it uses it's own appId as half the key, as Thomas correctly stated, and if multiple SqlRoleProviders are in play any roles that have been associated with that common user will be orphaned. Even if you use the RoleProvider to remove the user from all of its roles, the other apps don't know about this and a time bomb has been set.

In all of the tests this is the only problem with the strategy of using stock providers with creative configuration in this scenario lie in one short sproc, aspnet_Users_DeleteUser.

aspnet_Users_DeleteUser normally keys off the membership appId (applicationName) and the userId, which it gets by querying with the supplied username.

If we stipulate that this instance of aspnet_db is owned by the common membership provider and that only applications using the common membership provider shall connect, we can safely modify the aspnet_Users_DeleteUser to key off the username alone.

In looking at the sproc, you can see that a lot of work is being done, deleting membership rows, user rows, optionally cleaning associated data such as roles and profiles and all of these are being keyed by the GUID userId, not the userName that was passed in.

The solution to this problem was short and sweet. Instead of keying all of these deletions on a single GUID userId, at the head of the sproc, using the username, fill a table variable with all of the userId GUIDs that have the passed in username and then replace all of the 'where userid = @userId' with 'where userId in (select userId from @userIds)'.

Bingo, problem solved. All the way.

So the solution now looks like this: 

  • A common database and connection string,
  • A common membership application name,
  • A common machineKey section so that each site will use the common forms ticket.
  • A UNIQUE role provider application name.
  • A modified aspnet_Users_DeleteUser
  • A stipulation that each common membership provider owns it's instance of aspnet_db

Now, if Thomas is reading this, I am sure he is pwopping himself (google it) and wanting to reiterate his objections regarding the multiple user rows in aspnet_users as an indication of a defect in this strategy.

To which I say, to be crystal clear: aspnet_Users rows are NOT users. aspnet_Membership rows are users. aspnet_Users rows are more likened to user names. They are not authoritive in any  measurable way.

The RoleProvider and ProfileProvider will, when asked to provide services related to a user, create a row in aspnet_Users upon which to hang thier respective data, if a matching UserName/ApplicationId is not found. Which will be the case in this scenario.

These rows do not indicate that a new user has been created. Only the MembershipProvider can create a user. And all membership related actions are taken against the aspnet_Membership row and aspnet_Users row that the MembershipProvider created.

The 'extra' aspnet_User rows are, again, simply created as needed to enable the functionality of those providers. Only MembershipProvider is concerned with authentication. Role and Profile providers will take any username you want to give them and create roles/profiles for them whether they exists as Membership users or not. This is how anonymous profiles are implemented.

So, as long as these rows are kept pruned when a user deletion is enacted, every facet of the provider stack will work as advertised. Including all stock asp.net membership controls, the asp.net website configuration tool, direct access via provider instances etc etc.

Just a though: do you notice that no membership/profile/role related function, anywhere, takes a userId? The abstract, compartmentalized, Provider-ness of the stack revolves around the fact that all functions accept a UserName.  The bleed through from membership to roles/profiles exhibited in the Sql stack's implementation Membership.DeleteUser is a mixing of concerns that is necessary to make common scenarios 'Just Work'.

It is this single instance of mixing of concerns that we must compensate for in order to provide a viable solution to an interesting scenario with no compiled code to write or maintain or deploy, and just a single sproc replacement.

 

Download the test solution and see for yourself.

I despise MSTest for anything web related, so I have merged a few open source projects to create a rather interesting test setup.

Hosting is done with CassiniDev. I have merged the CassiniDev test fixture with an on-the-fly database fixture found in Salient.SqlServer and used a mini 'test-runner' call uUnit inside .ashx in the web apps to perform the tests.

Note: the versions of these components contained in this test solution are not 'released' yet, but I have committed them so you can pull the source if you like.

Here is the output of the tests. 

Smoke Test

------ Test started: Assembly: Tests.dll ------

Creating aspnet_db
Starting WebApplication1
@ WebApplication1
Create a common user on app1 and set local role and profile
	created role
	created user:testUser
	user exists
	added user to role
	created user profile
	user has profile
CreateUser : PASSED

Starting WebApplication2
@ WebApplication2
Ensure common user exists but no local role or profile exist
	user exists
EnsureUserExists : PASSED
	user has no profile
EnsureUserHasNoProfile : PASSED
	user has no role
EnsureUserHasNoRole : PASSED
Give the common user an application specific role and profile
	created role
CreateRole : PASSED
	added user to role
AddUserToRole : PASSED
	created user profile
CreateUserProfile : PASSED
	user has profile
EnsureUserHasProfile : PASSED

Starting WebApplication1
@ WebApplication1
Delete common user and ensure local roles and profiles are deleted
	user exists
	user has role
	user has profile
	deleted user:testUser via Membership.DeleteUser(Username);
	user does not exist
	user has no profile
	user has no role
DeleteUser : PASSED

Starting WebApplication2
@ WebApplication2
Common user was deleted in app1. Ensure roles and profile in app2 are deleted as well
	user does not exist
EnsureUserDoesNotExist : PASSED
EnsureUserHasNoRoles : PASSED
	user has no profile
EnsureUserHasNoProfile : PASSED

Dropping aspnet_db - comment out base.TestFixtureTearDown(); in TestFixtureTearDown to retain db for examination

4 passed, 0 failed, 0 skipped, took 34.48 seconds (NUnit 2.5.3).

Modified SPROC

--- Modified DeleteUser SP

IF (EXISTS (SELECT name
              FROM sysobjects
             WHERE (name = N'aspnet_Users_DeleteUser')
               AND (type = 'P')))
DROP PROCEDURE [dbo].aspnet_Users_DeleteUser
GO
CREATE PROCEDURE [dbo].[aspnet_Users_DeleteUser]
    @ApplicationName  nvarchar(256),
    @UserName         nvarchar(256),
    @TablesToDeleteFrom int,
    @NumTablesDeletedFrom int OUTPUT    

AS
BEGIN
	-- holds all user id for username
	DECLARE @UserIds TABLE(UserId UNIQUEIDENTIFIER)
    SELECT  @NumTablesDeletedFrom = 0

    DECLARE @TranStarted   bit
    SET @TranStarted = 0

    IF( @@TRANCOUNT = 0 )
    BEGIN
	    BEGIN TRANSACTION
	    SET @TranStarted = 1
    END
    ELSE
	SET @TranStarted = 0

    DECLARE @ErrorCode   int
    DECLARE @RowCount    int

    SET @ErrorCode = 0
    SET @RowCount  = 0

	-- get all userid for username
	INSERT INTO @UserIds
    SELECT  UserId
    FROM    dbo.aspnet_Users 
    WHERE   LoweredUserName = LOWER(@UserName)

DECLARE @tmp int
SELECT @tmp = COUNT(*) FROM @UserIds
	IF NOT EXISTS(SELECT * FROM @UserIds)
		GOTO Cleanup

    -- Delete from Membership table if (@TablesToDeleteFrom & 1) is set
    IF ((@TablesToDeleteFrom & 1) <> 0 AND
        (EXISTS (SELECT name FROM sysobjects WHERE (name = N'vw_aspnet_MembershipUsers') AND (type = 'V'))))
    BEGIN
        DELETE FROM dbo.aspnet_Membership WHERE UserId IN (SELECT UserId from @UserIds)

        SELECT @ErrorCode = @@ERROR,
               @RowCount = @@ROWCOUNT

        IF( @ErrorCode <> 0 )
            GOTO Cleanup

        IF (@RowCount <> 0)
            SELECT  @NumTablesDeletedFrom = @NumTablesDeletedFrom + 1
    END

    -- Delete from aspnet_UsersInRoles table if (@TablesToDeleteFrom & 2) is set
    IF ((@TablesToDeleteFrom & 2) <> 0  AND
        (EXISTS (SELECT name FROM sysobjects WHERE (name = N'vw_aspnet_UsersInRoles') AND (type = 'V'))) )
    BEGIN
        DELETE FROM dbo.aspnet_UsersInRoles WHERE UserId IN (SELECT UserId from @UserIds)

        SELECT @ErrorCode = @@ERROR,
                @RowCount = @@ROWCOUNT

        IF( @ErrorCode <> 0 )
            GOTO Cleanup

        IF (@RowCount <> 0)
            SELECT  @NumTablesDeletedFrom = @NumTablesDeletedFrom + 1
    END

    -- Delete from aspnet_Profile table if (@TablesToDeleteFrom & 4) is set
    IF ((@TablesToDeleteFrom & 4) <> 0  AND
        (EXISTS (SELECT name FROM sysobjects WHERE (name = N'vw_aspnet_Profiles') AND (type = 'V'))) )
    BEGIN
        DELETE FROM dbo.aspnet_Profile WHERE UserId IN (SELECT UserId from @UserIds)

        SELECT @ErrorCode = @@ERROR,
                @RowCount = @@ROWCOUNT

        IF( @ErrorCode <> 0 )
            GOTO Cleanup

        IF (@RowCount <> 0)
            SELECT  @NumTablesDeletedFrom = @NumTablesDeletedFrom + 1
    END

    -- Delete from aspnet_PersonalizationPerUser table if (@TablesToDeleteFrom & 8) is set
    IF ((@TablesToDeleteFrom & 8) <> 0  AND
        (EXISTS (SELECT name FROM sysobjects WHERE (name = N'vw_aspnet_WebPartState_User') AND (type = 'V'))) )
    BEGIN
        DELETE FROM dbo.aspnet_PersonalizationPerUser WHERE UserId IN (SELECT UserId from @UserIds)

        SELECT @ErrorCode = @@ERROR,
                @RowCount = @@ROWCOUNT

        IF( @ErrorCode <> 0 )
            GOTO Cleanup

        IF (@RowCount <> 0)
            SELECT  @NumTablesDeletedFrom = @NumTablesDeletedFrom + 1
    END

    -- Delete from aspnet_Users table if (@TablesToDeleteFrom & 1,2,4 & 8) are all set
    IF ((@TablesToDeleteFrom & 1) <> 0 AND
        (@TablesToDeleteFrom & 2) <> 0 AND
        (@TablesToDeleteFrom & 4) <> 0 AND
        (@TablesToDeleteFrom & 8) <> 0 AND
        (EXISTS (SELECT UserId FROM dbo.aspnet_Users WHERE UserId IN (SELECT UserId from @UserIds))))
    BEGIN
        DELETE FROM dbo.aspnet_Users WHERE UserId IN (SELECT UserId from @UserIds)

        SELECT @ErrorCode = @@ERROR,
                @RowCount = @@ROWCOUNT

        IF( @ErrorCode <> 0 )
            GOTO Cleanup

        IF (@RowCount <> 0)
            SELECT  @NumTablesDeletedFrom = @NumTablesDeletedFrom + 1
    END

    IF( @TranStarted = 1 )
    BEGIN
	    SET @TranStarted = 0
	    COMMIT TRANSACTION
    END

    RETURN 0

Cleanup:
    SET @NumTablesDeletedFrom = 0

    IF( @TranStarted = 1 )
    BEGIN
        SET @TranStarted = 0
	    ROLLBACK TRANSACTION
    END

    RETURN @ErrorCode

END
GO


Update: Thomas is doing his best to drive me to prove that things are not always as complicated as they may seem.

Apparently, I am screwing the pooch by not providing functionality that does not exist in the provider stack (for good reason, in my opinion), and I mouthed off quickly as I am wont to do that a 20 line sproc would take care of the problem.

I was wrong.

It only took 4 lines.

CREATE PROCEDURE aspnet_ChangeUserName(@oldUsername nvarchar(256),	@newUsername nvarchar(256))

	-- your code must verify that the new username meets provider requirement
	-- i.e. length, complexity
AS
	-- ensure new username is unique
	IF EXISTS(SELECT LoweredUserName FROM aspnet_Users WHERE LoweredUserName = LOWER(@newUsername))
		RETURN -1
	-- update aspnet_Users
	UPDATE aspnet_Users SET UserName = @newUsername, LoweredUserName = LOWER(@newUsername) WHERE LoweredUserName = LOWER(@oldUsername)
	RETURN 0

I tried to find the need for exception handling and transactions but there are none.

Of course you would need to ensure the user was logged off when you change the username, but that would be the case in any implementation, whether you decided to rewrite the provider stack or add a 4 line sproc. ;-)

 

Next?

Print | posted on Friday, March 05, 2010 2:29 AM |

Feedback

Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

Hey sky. There is one more angle that screws the pooch: changing a username. As I mentioned in one of the posts, even though neither provider provides a means to change someone's username, this is commonly implemented in SqlProvider structures. Imagine we have a user "AliceA". She's created in our central user list. Then we assign "AliceA" to WebApp1's "RoleA". Now we know at this point, we'll have two records in aspnet_Users: one with an AppId pointing to our central user list application and one pointing to WebApp1's application. Now we change AliceA to AliceB because she got married and wants to use her new last name. We do this by, outside the membership provider, altering her username record in aspnet_Users for the central user list application. None of the checks against role membership will work. Worse yet, if we do not catch it and we add another AliceA representing a different person, she be in all the roles in which the previous AliceA was a member.

At the end of the date, the core issue is that the RoleProvider, at the data layer, makes a clone of the User when it assigns a user to a role. That is what I had to change in my custom RoleProvider to correct this issue. Clearly, the RoleProvider was not designed to work in an environment where the App of the MembershipProvider and the app of the RoleProvider were different. They were made to work in tandem where if you had twenty applications, each application's user and role lists was entirely isolated from any other application.

(BTW, nice to know I'm not as crazy as you made me out to be ;->)
3/5/2010 4:14 AM | Thomas
Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

I would not characterise the lack of a function for which there is no equivalent ANYWHERE in the provider API 'screwing the pooch'.

In this scenario, that requirement was not present. If it were, it would require just one sproc.

Not custom provider code.

I don't think you are crazy, I think you are fastly clinging to you idea of what the code was and was not meant to do and that anything else is wrong.

So, if I read this correctly, the last objection is that since this usage lacks functionality that is not present in the provider API that I have screwed the pooch?

Would a 25 line sproc to implement the out-of-band functionality that you describe satisfy?




3/5/2010 4:46 AM | Sky
Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

It is odd that MS did not provide the ability to change a username. It is not a wholly uncommon request and in the systems I had built, it was specific request. In Windows you are not restricted from changing your username. Why should you be restricted in this system?. I'm surprised they did not add a flag like "EnableUsernameChange" to handle this scenario.

I can see, in the way that MS implemented the provider, that they never intended on it being used in a scenario with multiple apps and a single auth store. If they had, they would have altered the functionality. The only thing I cling to is the evidence of code behavior that I observe. Having had to build this very model and having tried using the SqlProviders, I was aware that out of the box the functionality was deceptive. It *looked* like it worked but in realty it was buggy.

My complaint with the way the SqlProviders are designed is that, out of the box, the RoleProvider clones the user. At the data layer, it is not setting a reference to the "real" user, it using an indirect reference through the username. That, to me, is a significant flaw. If you were designing a data structure for multiple apps and a single store, you wouldn't design it that way.

As I said in a comment on SO, yes you could alter the SP. However, if another developer comes along, it is not at all clear that you have altered the built-in functionality. I suppose you could redirect the sp to custom stored procs.
3/5/2010 5:13 AM | Thomas
Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

What say let's keep it here, back and forth is getting me dizzy and the comment box is bigger here.

Dude, the reason for the users table is specifically to enable loosely coupled providers. That is the point I have been trying to make for the past 2 days.

Yes, the sql providers bleed into each other a bit, but it is obvious where this happens and is easily compensated for. I don't see a reason to characterise it as buggy.

And, yes, of course, if you were to design a specific solution for user management that could survive tight coupling of authentication and other meta you would be justified in eliminating the junction table.

The asp.net provider stack had to be designed and implemented in such a was as to be many things to many people. I used to question the design myself, but the more I have worked with it I realize people much smarted than I were involved in delivering it, fortunately.

So - I am still feeling that you are not acknowledging the true nature of the users table, leading you to feel it badly designed.

Any idea why I am getting this impression?
3/5/2010 5:32 AM | Sky
Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

oops, regarding lack of username change. I am pretty sure it is by design to maintain the username as a constant.

I agree that there are cases that require name change and can be accomplished easily with no side effects in the standard provider stack.

Why I don't like username change in aspnet_db? Now that I think about it, I don't really know any more. I do know I have been bitten, but must not have been that bad. Just bad enough to make me averse to providing that functionality.
3/5/2010 5:44 AM | Sky
Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

If the Providers were designed to be loosely coupled on username, then a simpler design would have been PK in the aspnet_users table based on app id and Username with cascade update. (BTW, have you noticed that there is no unique constraint on app id and username in the aspnet_users table?) The use of a surrogate key complicated the design and created the holes we have both seen. It's a data schema that was not thought out for scenarios we have been discussing. I'm not saying it is the end of the world and obviously it can be corrected to allow for this design. We each used our own respective approach to do just that. My biggest beef with MS on this one is that it looks like it should work and sort of does out of the box. Just use the same app name for the MP a different app name for each app's RP and off you go. Until you look at what it is actually doing you don't realize that MS did not consider this use case scenario.
3/6/2010 4:50 AM | Thomas
Gravatar

# re: Multiple ASP.Net Apps with common SqlMembershipProvider and unique SqlRoleProvider/SqlProfileProvider

Thomas,
It has become quite clear that the actual usage of the aspnet_Users table and it's role in decoupling the providers and enabling an extensibe system is something you are not able/willing to acknowledge as anything other that a bug or poor design.

Why that is, I am not sure, but I have done my best to try and describe it.

One thing I have noticed over the course of our interaction is a tendency to focus on either inaccurate observations or red herrings in support of an argument and this makes it hard to justify extending the conversation. I could be mistaken but this is how I am seeing it.

BTW - the constraint is on ApplicationId (ASC), LoweredUserName (ASC).
3/6/2010 8:03 AM | Sky

Post Comment

Title  
Name  
Email
Url
Comment   
Please add 7 and 4 and type the answer here:

Powered by: