This post is part 6 in a series on integrating Blackbaud Infinity based applications such as Blackbaud Enterprise CRM (BBEC) into an Enterprise Service Bus (ESB) architecture using a WCF Workflow service hosted in Windows Server AppFabric.
- Part 1 – Intro
- Part 2 – ESB Message pass-through to BBEC workflow activity
- Part 3 – ESB Message translation to BBEC IDs and operations
- Part 4 – Deploying to Windows Server AppFabric on IIS
- Part 5 – Asynchronous message handling with SQL persistence
This post will cover securing access to the ESB adapter service. I will show how to ensure that only authorized principals can send a message to the adapter service.
Inbound Security Authentication & Authorization
In part 4 of this series we moved our application from the local ASP.Net development web server to IIS. In the process we realized we had to deal with the security requirements for the outgoing calls from the adapter service to the Infinity web service. We used the Trusted Subsystem Model pattern where the account that the ESB adapter service is running under is treated as a trusted subsystem with a set of the least privileges needed to carry out only and exactly the operations that are used by the workflow service. We placed the account of the workflow service into a BBEC System Role and gave it permissions to only the BBEC features that are used by the workflow. This took care of the outgoing security from the adapter service into Infinity.
However, up until now we have failed to take into account the incoming security- which is a potentially glaring omission. In the current implementation of our service up until Part 5 of this series we have a pretty wide open front door to add a new record to BBEC because our IIS application that hosts the ESB adapter is configured to allow Anonymous access.
This means that currently anyone who can make an HTTP POST to our endpoint on this server can send a message and the adapter will dutifully forward it on to the BBEC application even though the caller may be completely anonymous. If we don’t correct this then if we have a mischievous and bored summer intern on staff we can probably look forward to a few prank “Seymore Butz” and “”Amanda Hugginkiss” Constituent records showing up in our database. Obviously we need to plug this hole.
There are multiple ways we can seal off this service from unauthorized access. A brute force approach could be to secure the service behind some kind of firewall (hardware or software) that only allows messages to this service from a well known server that is the actual ESB server. Such hardening of access may be a good idea as a way to implement a Defense In Depth strategy, but I would feel much better if the service itself had its own implementation of proper user authentication and authorization in addition to any external access control.
AppFabric, where are you?
Securing access to the workflow service is an area that Workflow Foundation and AppFabric are surprisingly… how can I say this nicely…. not in the business of helping us out. According to the AppFabric documentation on MSDN:
The primary goal of the AppFabric security model is to provide a simple, yet effective, mechanism for the majority of AppFabric users. Because of its integration with existing Windows, .NET Framework, IIS, and SQL Server security models, users can leverage existing security knowledge and skill sets to use the AppFabric security model. Specifically it uses Windows, .NET Framework, IIS, and SQL Server security concepts to enforce different levels of security on the WCF and WF applications it manages. Because AppFabric adds only minor enhancements to an already robust integrated Microsoft security picture, its security model is familiar to administrators who are knowledgeable in Microsoft security concepts. This results in a lower long-term total cost of ownership for AppFabric customers. If you are already familiar with these products and technologies, you can easily secure your application by following the guidance in the Security and Protection section.
Allow me to translate in my own words:
When it comes to securing access to the workflow service, go read up on how you do that with WCF because all an AppFabric service is really is just another WCF service. AppFabric workflow services security punts to all that existing stuff so you get no help from WF or AppFabric beyond what comes with WCF. Just secure it like you would any other WCF service.
This delegation to existing WCF functionality is something that I can respect because there are some good things about being compatible with existing concepts and implementation. But I do feel a little disappointed that neither WF or AppFabric have done anything to take the level of abstraction up a notch with WCF authentication in the same way that they have with so much else. In fact, in some ways it is actually more work to secure a workflow service because you can’t take advantage of the PrincipalPermissionAttribute that would allow you to declaratively decorate a WCF service method implemented in procedural code. The result is that paradoxically you can use declarative security in procedural WCF code but you can’t use the same easy technique in declarative workflow services. (Personally this makes me want to put a T in the middle of the WF abbreviation).
Interestingly, there is a project from Microsoft up on CodePlex called the WF Security Pack CTP 1 that includes an activity named “PrinciplePermissionScope” that is exactly the kind of security wrapper we need. According to the documentation of the "CTP status" activity:
PrincipalPermissionScope
The PrincipalPermissionScope activity enforces authorization within the workflow by performing a principal permission check against a client-provided identity.
After a message is received (via a Receive activity within the scope of the Body), the authenticated client identity is checked against the principal permission values specified in the PrincipalPermissionName and PrincipalPermissionRole arguments. This permission demand is done in the same way as enforced by the PrincipalPermission class or attribute, and therefore it also supports ASP.NET Role Providers in addition to WindowsIdentity.
The following example demonstrates how to use a PrincipalPermissionScope activity to authorize an UpdatePurchaseOrder request submitted by a client who must be an Administrator:
The PrincipalPermissionScope is exactly the kind of declarative security mechanism that I would expect to be available with WF, however it is implemented in an open source project on CodePlex, not in the Framework itself as a first class supported feature. We can hope that a future version of WF will include this activity, but in the meantime I would prefer to stay away from it as long as it remains in CTP status.
We finally get to write some code.
So if the bad news is that there is not a nice clean fully supported first class declarative mechanism for implementing authorization, then the good news is that we finally get to write some .Net code in our adapter implementation. Securing our service will involve the following:
- Authentication – Our service will be configured for Integrated Windows Authentication. Anonymous access will be denied
- Authorization – Our service will check if the caller is a member of a local security group named BBECAdapter_Callers and only accept the message if the calling identity is in that group.
#1 (authentication) is implemented by configuring the web.config file.
#2 (authorization) must be implemented in code by a class that inherits from System.ServiceModel.ServiceAuthorizationManager
Enable Windows Authentication
To enable Windows Authentication for the service edit the web.config system.webServer\Security element:
<system.webServer> <modules runAllManagedModulesForAllRequests="true"/> <security> <authentication> <windowsAuthentication enabled="true" /> <anonymousAuthentication enabled="false"/> </authentication> </security> </system.webServer>
and in the system.serviceModel\bindings\basicHttpBinding element set the security mode to “TransportCredentialOnly” and the transport client credential type to “Windows”
<bindings> <basicHttpBinding> <!--This binding is used as the default for all HTTP / Port 80 requests--> <binding name=""> <!--Security mode TransportCredentialOnly requires Windows Authentication--> <security mode="TransportCredentialOnly"> <transport clientCredentialType="Windows" /> </security> </binding> </basicHttpBinding> </bindings>
Implement ServiceAuthorizationManager
While we are editing the web.config file we can go ahead and add a placeholder pointer to our ServiceAuthorizationManager implementation, which will be named ServiceAuthManager. Add the <serviceAuthorization> behavior in the system.serviceModel\behaviors\serviceBehaviors\behavior element:
<behaviors> <serviceBehaviors> <behavior> <!-- To avoid disclosing metadata information, set the value below to false and remove the metadata endpoint above before deployment --> <serviceMetadata httpGetEnabled="true"/> <!-- To receive exception details in faults for debugging purposes, set the value below to true. Set to false before deployment to avoid disclosing exception information --> <serviceDebug includeExceptionDetailInFaults="true"/> <sqlWorkflowInstanceStore connectionStringName="BBECBusAdapterInstanceStoreConnection" /> <serviceAuthorization serviceAuthorizationManagerType="BBECBusAdapter.ServiceAuthManager,BBECBusAdapter" /> </behavior> </serviceBehaviors> </behaviors>
In my authorization logic I am going to check if the caller identity is in at least one of three local security groups:
- Local Administrators Group
- A local administrator of the web server should probably be allowed to do anything
- The AS_Administrators group that AppFabric installs by default
- Members of this group are intended to be administrators of all AppFabric apps, so it makes sense that they should be able to make calls to the adatper
- A local security group named BBECBusAdapter_Callers
- This is the group that the ESB calling principal should be placed in
Implementing this check is straightforward. In the constructor of the ServiceAuthorizationManager I will grab the account SIDs for those three local security groups:
Public Class ServiceAuthManager Inherits ServiceAuthorizationManager 'sid for local AS_Administrators group Private _appServerAdministratorsWindowsGroupSid As SecurityIdentifier 'sid for local Administrtors group Private _builtInAdminsSid As SecurityIdentifier 'Sid for local security group BBECBusAdapter_Callers Private _esbAdapterServiceWorkflowUsers As SecurityIdentifier Public Sub New() MyBase.New() 'builtin\Administrators _builtInAdminsSid = New SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, Nothing) 'AS_Administrators _appServerAdministratorsWindowsGroupSid = GetAppServerAdministratorsWindowsGroup() 'BBECBusAdapter_Callers _esbAdapterServiceWorkflowUsers = GetESBWorkflowServiceUsersWindowsGroup() End Sub
and then in CheckAccessCore I will simply call System.Security.WindowsPrincipal.IsInRole() for each of the three SIDs and return true if the incoming caller principal is in any of the groups. Before returning False in the case where the caller is not in any role I will write a detailed error message to the Windows Application Event log.
Protected Overrides Function CheckAccessCore(ByVal operationContext As System.ServiceModel.OperationContext) As Boolean If operationContext.ServiceSecurityContext.IsAnonymous Then RaiseWebHealthEvent("An anonymous request was made to the workflow service. WorkflowServiceAuthorizationManager.CheckAccesCore will return 'false' to deny access.") Return False End If Dim incomingCallerWinIdentity = operationContext.ServiceSecurityContext.WindowsIdentity Dim incomingCallerWinPrincipal = New WindowsPrincipal(incomingCallerWinIdentity) 'ok if the incoming caller is in the Administrators local security group If incomingCallerWinPrincipal.IsInRole(_builtInAdminsSid) Then Return True End If 'ok if the incoming caller is in the AS_Administrators local security group If _appServerAdministratorsWindowsGroupSid IsNot Nothing Then If incomingCallerWinPrincipal.IsInRole(_appServerAdministratorsWindowsGroupSid) Then Return True End If End If If _esbAdapterServiceWorkflowUsers IsNot Nothing Then 'ok if the incoming caller is in the BBECBusAdapter_Callers group If incomingCallerWinPrincipal.IsInRole(_esbAdapterServiceWorkflowUsers) Then Return True End If End If 'build detailed error message, log it, and return false Dim sb As New Text.StringBuilder sb.AppendLine("A request to the workflow service was made by an unauthorized account. WorkflowServiceAuthorizationManager.CheckAccesCore will return 'false' to deny access.") sb.AppendLine("caller WindowsIdentity.Name: " & incomingCallerWinIdentity.Name) sb.AppendLine("caller WindowsIdentity.AuthType: " & incomingCallerWinIdentity.AuthenticationType) RaiseWebHealthEvent(sb.ToString) Return False End Function
My solution now has 1 new .vb file in it, so I’ve got 2 .vb files and 3 .xaml files and a web.config, so the ratio of declarative to procedural code is still very high.
With the ServiceAuthManager now in place, when I run the WCF Test client I get an “Access Denied” message:
I am running on a machine with UAC enabled so I my Windows account is not in the local administrators group. I am also not in the AS_Administrators group and I haven’t yet created a group called BBEC_BusAdapters yet. The service is correctly denying me access to send a message to it. In my implementation I used a Web Health event to log this authorization denial to the Windows Application event log. The event message reads:
“A request to the workflow service was made by an unauthorized account. WorkflowServiceAuthorizationManager.CheckAccesCore will return ‘false’ to deny access.
caller WindowsIdentity.Name: bbdev\PaulG”
Having this kind of logging in place can be very helpful when trying to troubleshoot problems with the service.
To be able to successfully call the service I need to grant access to my Windows account by placing it in one of the groups that the ServiceAuthManager recognizes. The PowerShell script below will create a local security group named BBEC_BusAdapters and add my account to it.
#create the local group named BBEC_BusAdapter #see http://blogs.technet.com/b/heyscriptingguy/archive/2010/11/24/use-powershell-to-create-local-windows-groups.aspx $localComputerName="paulgxps16" $groupName="BBECBusAdapter_Callers" $comp=[adsi]"WinNT://$localComputerName" $group=$comp.Create("Group",$groupName) $group.SetInfo() $group.description="Users in this group can call the BBECBusAdapter workflow service" $group.SetInfo() #add my Windows account to the group #see http://blogs.technet.com/b/heyscriptingguy/archive/2008/03/11/how-can-i-use-windows-powershell-to-add-a-domain-user-to-a-local-group.aspx $userName=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name $userName= $userName -replace ("\\","/") $userPath = "WinNT://$userName, user" $group.PSBase.Invoke("Add",$userPath)
After running that script I have a new local security group named BBECBusAdapter_Callers:
After logging out and logging back in my session now has this group token in my identity and I can successfully call the ESB adapter service from the WCFTestclient.exe.
Where are we?
We have now tightened up the security of our ESB adapter service. We made the following changes in this post:
- Modified web.config to require Windows Authentication
- Implemented a standard WCF ServiceAuthorizationManager class that checks that the caller is in at least one of a specific set of local security groups.
Web.config Source Code:
Here is the full web.config file for reference:
<configuration> <connectionStrings> <add name="appfx_connection" connectionString="applicationName=ESBAdapt;databaseName=BBInfinity_firebird;url=http://paulgxps16/bbappfx_firebird/appfxwebservice.asmx" /> <add name="BBECBusAdapterInstanceStoreConnection" connectionString="server=paulgxps16;database=BBECBusAdapterInstanceStore;integrated security=true" /> </connectionStrings> <system.web> <compilation debug="true" strict="false" explicit="true" targetFramework="4.0" /> <healthMonitoring> <eventMappings> <add name="ServiceAuth Events" type="BBECBusAdapter.ServiceAuthWebHealthEvent,BBECBusAdapter" /> </eventMappings> <rules> <add name="Event Log ServiceAuth Events" eventName="ServiceAuth Events" provider="EventLogProvider" profile="Critical" /> </rules> </healthMonitoring> </system.web> <system.serviceModel> <behaviors> <serviceBehaviors> <behavior> <!-- To avoid disclosing metadata information, set the value below to false and remove the metadata endpoint above before deployment --> <serviceMetadata httpGetEnabled="true"/> <!-- To receive exception details in faults for debugging purposes, set the value below to true. Set to false before deployment to avoid disclosing exception information --> <serviceDebug includeExceptionDetailInFaults="true"/> <sqlWorkflowInstanceStore connectionStringName="BBECBusAdapterInstanceStoreConnection" /> <serviceAuthorization serviceAuthorizationManagerType="BBECBusAdapter.ServiceAuthManager,BBECBusAdapter" /> </behavior> </serviceBehaviors> </behaviors> <bindings> <basicHttpBinding> <!--This binding is used as the default for all HTTP / Port 80 requests--> <binding name=""> <!--Security mode TransportCredentialOnly requires Windows Authentication--> <security mode="TransportCredentialOnly"> <transport clientCredentialType="Windows" /> </security> </binding> </basicHttpBinding> </bindings> <serviceHostingEnvironment multipleSiteBindingsEnabled="true" /> </system.serviceModel> <system.webServer> <modules runAllManagedModulesForAllRequests="true"/> <security> <authentication> <windowsAuthentication enabled="true" /> <anonymousAuthentication enabled="false"/> </authentication> </security> </system.webServer> <microsoft.applicationServer> <monitoring> <default enabled="true" connectionStringName="ApplicationServerMonitoringConnectionString" monitoringLevel="Troubleshooting" /> </monitoring> <persistence> <instanceStores> <add name="BBECBusAdapterStore" provider="SqlPersistenceStoreProvider" connectionStringName="BBECBusAdapterInstanceStoreConnection" /> </instanceStores> </persistence> </microsoft.applicationServer> </configuration>
ServiceAuthManager source code:
and here is the code in our ServiceAuthManager class:
Imports System.ServiceModel Imports System.Security.Principal Imports System.Web.Management Public Class ServiceAuthManager Inherits ServiceAuthorizationManager 'sid for local AS_Administrators group Private _appServerAdministratorsWindowsGroupSid As SecurityIdentifier 'sid for local Administrtors group Private _builtInAdminsSid As SecurityIdentifier 'Sid for local security group BBECBusAdapter_Callers Private _esbAdapterServiceWorkflowUsers As SecurityIdentifier Public Sub New() MyBase.New() 'builtin\Administrators _builtInAdminsSid = New SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, Nothing) 'AS_Administrators _appServerAdministratorsWindowsGroupSid = GetAppServerAdministratorsWindowsGroup() 'BBECBusAdapter_Callers _esbAdapterServiceWorkflowUsers = GetESBWorkflowServiceUsersWindowsGroup() End Sub Private Function GetAppServerAdministratorsWindowsGroup() As SecurityIdentifier Dim appServerAdmins = GetAppServerAdministratorsWindowsGroupName() If Not String.IsNullOrEmpty(appServerAdmins) Then Dim account As New NTAccount(appServerAdmins) Try Return DirectCast(account.Translate(GetType(SecurityIdentifier)), SecurityIdentifier) Catch exNotMapped As IdentityNotMappedException 'just report and eat the error. RaiseWebHealthEvent("Unable to map the App Server Administrators group to an NT account") End Try End If Return Nothing End Function Private Function GetESBWorkflowServiceUsersWindowsGroup() As SecurityIdentifier Dim groupName As String groupName = System.Configuration.ConfigurationManager.AppSettings.Get("BBECBusAdapter_AuthorizedWindowsGroup") If String.IsNullOrEmpty(groupName) Then groupName = "BBECBusAdapter_Callers" End If Dim account As New NTAccount(groupName) Dim identifier As SecurityIdentifier = Nothing Try identifier = DirectCast(account.Translate(GetType(SecurityIdentifier)), SecurityIdentifier) Catch exNotMapped As IdentityNotMappedException Dim msg As String = String.Format("Unable to map account {0} specified in the BBECBusAdapter_AuthorizedWindowsGroup app setting value. Error={1}", groupName, exNotMapped.Message) RaiseWebHealthEvent(msg) 'just report and eat the error. End Try Return identifier End Function Private Function GetAppServerAdministratorsWindowsGroupName() As String 'Use the WorkflowInstanceManagementElement setting for AuthorizedWindowsGroup. Dim behaviorsSection = TryCast(System.Configuration.ConfigurationManager.GetSection("system.serviceModel/behaviors"), System.ServiceModel.Configuration.BehaviorsSection) If behaviorsSection IsNot Nothing Then If behaviorsSection.ServiceBehaviors IsNot Nothing Then For Each section In behaviorsSection.ServiceBehaviors Dim serviceBehaviorElm = TryCast(section, System.ServiceModel.Configuration.ServiceBehaviorElement) If serviceBehaviorElm IsNot Nothing Then For i = 0 To serviceBehaviorElm.Count Dim item = serviceBehaviorElm.Item(i) Dim wfManInstance = TryCast(item, System.ServiceModel.Activities.Configuration.WorkflowInstanceManagementElement) If wfManInstance IsNot Nothing Then ' RaiseWebHealthEvent("ServiceAuthManager windows group = " & wfManInstance.AuthorizedWindowsGroup) Return wfManInstance.AuthorizedWindowsGroup End If Next End If Next End If End If Return "AS_Administrators" End Function Protected Overrides Function CheckAccessCore(ByVal operationContext As System.ServiceModel.OperationContext) As Boolean If operationContext.ServiceSecurityContext.IsAnonymous Then RaiseWebHealthEvent("An anonymous request was made to the workflow service. WorkflowServiceAuthorizationManager.CheckAccesCore will return 'false' to deny access.") Return False End If Dim incomingCallerWinIdentity = operationContext.ServiceSecurityContext.WindowsIdentity Dim incomingCallerWinPrincipal = New WindowsPrincipal(incomingCallerWinIdentity) 'ok if the incoming caller is in the Administrators local security group If incomingCallerWinPrincipal.IsInRole(_builtInAdminsSid) Then Return True End If 'ok if the incoming caller is in the AS_Administrators local security group If _appServerAdministratorsWindowsGroupSid IsNot Nothing Then If incomingCallerWinPrincipal.IsInRole(_appServerAdministratorsWindowsGroupSid) Then Return True End If End If If _esbAdapterServiceWorkflowUsers IsNot Nothing Then 'ok if the incoming caller is in the BBECBusAdapter_Callers group If incomingCallerWinPrincipal.IsInRole(_esbAdapterServiceWorkflowUsers) Then Return True End If End If 'build detailed error message, log it, and return false Dim sb As New Text.StringBuilder sb.AppendLine("A request to the workflow service was made by an unauthorized account. WorkflowServiceAuthorizationManager.CheckAccesCore will return 'false' to deny access.") sb.AppendLine("caller WindowsIdentity.Name: " & incomingCallerWinIdentity.Name) sb.AppendLine("caller WindowsIdentity.AuthType: " & incomingCallerWinIdentity.AuthenticationType) RaiseWebHealthEvent(sb.ToString) Return False End Function Private Sub RaiseWebHealthEvent(msg As String) Dim evt As New ServiceAuthWebHealthEvent(msg, Me) Web.Management.WebBaseEvent.Raise(evt) End Sub End Class Public Class ServiceAuthWebHealthEvent Inherits System.Web.Management.WebBaseEvent Public Const SERVICEAUTH_EVENT_BASECODE As Integer = WebEventCodes.WebExtendedBase + 2400000 Public Sub New(msg As String, source As Object) MyBase.New(msg, source, SERVICEAUTH_EVENT_BASECODE) End Sub End Class