Step 5: Build multi-tenant interaction recording authorized using activity data authorization (ADA)

Note

This is a continuation step of the Getting started with ASK on ASP.NET Core MVC walkthrough. To see the concepts explained here in action, copy over the code blocks below to the relevant files/folders of the SuperCRM project developed in the earlier step, or else you can get the previous step code from here.

Introduction

As discussed in the requirements step, keeping a record of interactions with customers is the most important function of a CRM system. In this step we’re going to build a grid-based interface to let users of our system record and manage past and future interactions with their customers and prospects.

Apart from building the CRUD operations, we need to also make sure that each user can only access and modify interactions for contacts that belongs to him. As we saw in the previous step about building contact management, ASK’s Activity-Data Authorization (ADA) will help us in applying this access-control automatically often without needing to write any line of authorization logic.

Interaction view model

Following the same approach, we also define the Interaction view model:

(Add the following code as Interaction.cs into the folder Models.)

using System;
using System.ComponentModel.DataAnnotations;
using SuperCRM.DataModels;

namespace SuperCRM.Models
{
	public class Interaction
	{
		public Guid? Id { get; set; }

		[Required]
		public Guid? ContactId { get; set; }

		public InteractionMethod Method { get; set; }

		[MaxLength(256)]
		public string MethodDetails { get; set; }

		public string Notes { get; set; }

		public DateTime InteractionDate { get; set; }

		public DateTime CreatedDate { get; set; }

		public string CreatedByName { get; set; }
	}
}

Automapper mapping

Define mappings for interaction models in the mapping profile:

public WebAppProfile()
{
	...
	CreateMap<DbInteraction, Interaction>()
		.ForMember(d => d.CreatedByName, o => o.MapFrom(s => s.CreatedBy.Name));

	CreateMap<Interaction, DbInteraction>()
		.ForMember(d => d.Id, o =>
		{
			o.PreCondition((s, d, rc) => d.Id == Guid.Empty);
			o.MapFrom(s => Guid.NewGuid());
		})
		.ForMember(d => d.CreatedById, o => o.Ignore())
		.ForMember(d => d.CreatedDate, o =>
		{
			o.PreCondition((s, d, rc) => d.CreatedDate == DateTime.MinValue);
			o.MapFrom(s => DateTime.UtcNow);
		});
}

For the mapping from view model to data entity, we’re ignoring properties such as CreatedById – these will be initialized by the server from the CurrentUser. Both Id and CreatedDate are also not accepting input from user – instead are being initialized in the mapping itself, but only when the destination has no value – which means that a new entity is being created, as otherwise these properties are read-only during updates.

Interaction CRUD operations

Add the following code as InteractionController.cs into the folder Controllers:

using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using ASPSecurityKit;
using ASPSecurityKit.Net;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
using SuperCRM.DataModels;
using SuperCRM.Models;

namespace SuperCRM.Controllers
{
	public class InteractionController : ServiceControllerBase
	{
		private readonly AppDbContext dbContext;
		private readonly IMapper mapper;

		public InteractionController(AppDbContext dbContext, IMapper mapper, IUserService<Guid, Guid, DbUser> userService,
			INetSecuritySettings securitySettings, ISecurityUtility securityUtility, ILogger logger) :
			base(userService, securitySettings, securityUtility, logger)
		{
			this.dbContext = dbContext;
			this.mapper = mapper;
		}

		[PossessesPermissionCode]
		public ActionResult Index()
		{
			return View();
		}

		[HttpPost]
		[PossessesPermissionCode, AuthAction("Index")]
		public async Task<ActionResult> List(int jtStartIndex, int jtPageSize, Guid? contactId = null)
		{
			return await SecureJsonAction(async () =>
			{
				Expression<Func<DbInteraction, bool>> predicate;
				if (contactId.HasValue)
					predicate = i => i.ContactId == contactId.Value;
				else
					predicate = i => i.Contact.OwnerId == this.UserService.CurrentUser.OwnerUserId;

				var result = new
				{
					Total = await this.dbContext.Interactions.CountAsync(predicate),
					ThisPage = await this.dbContext.Interactions.Where(predicate)
						.OrderByDescending(p => p.InteractionDate).Skip(jtStartIndex).Take(jtPageSize).AsQueryable()
						.ProjectTo<Interaction>(mapper.ConfigurationProvider).ToListAsync()
				};

				return Json(ApiResponse.List(result.ThisPage, result.Total));
			});
		}

		[HttpPost, ValidateAntiForgeryToken]
		public async Task<ActionResult> Add(Interaction model)
		{
			return await SecureJsonAction(async () =>
			{
				if (ModelState.IsValid)
				{
					var entity = mapper.Map<DbInteraction>(model);
					entity.CreatedById = this.UserService.CurrentUserId;
					this.dbContext.Interactions.Add(entity);
					await this.dbContext.SaveChangesAsync();
					return Json(ApiResponse.Single(mapper.Map<Interaction>(entity)));
				}

				throw new OpException(OpResult.InvalidInput);
			});
		}

		[HttpPost, ValidateAntiForgeryToken]
		public async Task<ActionResult> Edit(Interaction model)
		{
			return await SecureJsonAction(async () =>
			{
				if (ModelState.IsValid)
				{
					var entity = await this.dbContext.Interactions.FindAsync(model.Id);
					if (entity == null)
						throw new OpException(OpResult.DoNotExist, "Interaction not found.");

					mapper.Map(model, entity);
					await this.dbContext.SaveChangesAsync();
					return Json(ApiResponse.Single(mapper.Map<Interaction>(entity)));
				}

				throw new OpException(OpResult.InvalidInput);
			});
		}

		[HttpPost, ValidateAntiForgeryToken]
		public async Task<ActionResult> Delete(Guid id)
		{
			return await SecureJsonAction(async () =>
			{
				var entity = await this.dbContext.Interactions.FindAsync(id);
				if (entity == null)
					throw new OpException(OpResult.DoNotExist, "Interaction not found.");

				this.dbContext.Remove(entity);
				await this.dbContext.SaveChangesAsync();
				return Json(ApiResponse.Success());
			});
		}
	}
}

Here also, the UI is built using jTable and hence we have only one MVC operation – Index – rest all are API operations returning JSON.

The List operation takes an optional contactId parameter. This is because we’re going to support two separate UIs for interactions:

  1. Stand-alone: Invoked via Index operation, this interface will show an unfiltered list of interactions in recent-first order for all contacts belonging to the account. The contactId will be null in such case and hence we’re querying by the OwnerUserId.
  2. Popup: Invoked via the [contacts UI], it’ll be a popup interface, and shall show filtered list of interactions in recent-first order for the contact user chose in the contacts UI.

In both Add and Edit operations, after checking that we’ve got a valid model, we use AutoMapper to transfer data from the view model to data model. CreatedById is assigned by us based on the CurrentUser during creation, and it’s read-only during modification for obvious reasons.

In the Delete operation, we perform a hard delete of the specified interaction. However, in the real system, you may rather perform a soft delete – marking the interaction as deleted rather than physically deleting it.

Note

Similar to contact’s CRUD operations, you see use of SecureJsonAction, OpException, APIResponse etc. here. These are elements part of MVC template you’ve got. These utilities streamline secure and rapid development with proper, graceful error handling. Starter and Premium source packages have even more extensive version of these routines, out of which a basic form has been extracted into the MVC template we’re using for the tutorial.

Authorization

Make sure you’ve gone through the authorization section of the previous step – building contact management – because here for interaction operations, we’ve leveraged the same concepts and tools of activity-data authorization (ADA) explained in detail over there.

First, let’s define the list of permissions for interaction related operations in the table below:

Operation (Activity) Permission
Index IndexInteraction
List IndexInteraction
Add AddInteraction
Edit EditInteraction
Delete DeleteInteraction

Now let’s define its implied permissions:

Permission Implied
AddInteraction IndexInteraction
EditInteraction AddInteraction
DeleteInteraction EditInteraction
IndexContacts IndexInteractions
DeleteContact DeleteInteraction
EditContact EditInteraction

Similar to what we had done for contact’s implied permissions, we’ve defined implied permissions for interaction based on an increasingly privileged permission hierarchy. However, you may have noticed that we’ve defined three more records – index, delete and edit contact permissions, implying their interaction counterparts. This is because we feel that it’s logical to assume that if a user has these permissions on contacts, he should have the same permissions on their interaction records as well.

You may also notice that we haven’t defined the Customer role permission implying DeleteInteraction but we did it in case of DeleteContact. As you might have already guessed, it’s indirectly implied by being declared as an implied of DeleteContact!

Tip

The Premium source package comes with an Organize Permissions feature that provides a fully functional interface to manage permissions and their implied permissions for the prospective admins of the system you develop with ASPSecurityKit.

You should now insert these new permissions and implied permissions data into the relevant tables as part of a migration.

First, add a new empty migration with the command,

add-migration InteractionPermissions

Next, replace the Up method which would be empty with the one below:

protected override void Up(MigrationBuilder migrationBuilder)
{
	var permissions = new[]
	{
		"('IndexInteraction', 'Interaction', 'List Interactions', 0)", // general permission
		"('AddInteraction', 'Interaction', 'Add new Interaction', 0)", // general permission
		"('EditInteraction', 'Interaction', 'Modify Interaction', 1)", // instance permission
		"('DeleteInteraction', 'Interaction', 'Delete Interaction', 1)", // instance permission
	};

	var impliedPermissions = new[]
	{
		"('AddInteraction', 'IndexInteraction')",
		"('EditInteraction', 'AddInteraction')",
		"('DeleteInteraction', 'EditInteraction')",
		"('IndexContact', 'IndexInteraction')",
		"('EditContact', 'EditInteraction')",
		"('DeleteContact', 'DeleteInteraction')"
	};

	var permissionsSql = new[]
	{
		@"insert into [dbo].[Permission](PermissionCode, EntityTypeCode, Description, Kind)values" + string.Join(",\r\n", permissions),
		@"insert into [dbo].[ImpliedPermission]
		(PermissionCode, ImpliedPermissionCode)values" + string.Join(",\r\n", impliedPermissions)
	};

	foreach (var script in permissionsSql)
	{
		migrationBuilder.Sql(script);
	}
}

Finally, execute the migration and insert the above data:

update-database

We now define a related references loader for interactionId the same way we did for contactId:

(Again, you define this method inside AuthDefinitions\ReferencesProvider.cs which comes as part of the template.)

public async Task<List<IdReference>> InteractionId(Guid interactionId)
{
	return await dbContext.Interactions
		.Where(x => x.Id == interactionId)
		.Select(x => new List<Guid> { x.Id, x.ContactId, x.Contact.OwnerId })
		.ToIdReferenceListAsync();
}

So, with above loader method, what we’re trying to say is that to authorize interactionId in Edit or Delete interaction operations for example, we can either use its contactId, contact’s ownerId or interactionId itself. We can just load the ownerId as we don’t plan to grant permit on interaction or contact entities specifically. But, we keep it this way to show you that those are also a possibility with related references. For example in financial systems, a read-only access can be granted as beneficiary (nominee) to users on financial accounts belonging to some other users.

Tip

You can go through SuperFinance demo/tutorial to see an example of such a system prototype developed using ASPSecurityKit and its Premium source package.


Now, as Customer permit is loaded, CRUD permits related to both contact and interaction will also be recursively loaded with userId as the entityId for all of them. The related references loaders for both contactId and interactionId will help in authorizing any operation that refers these identifiers. Therefore, the user will be able to perform CRUD operations related to interactions on any contact that belongs to that user – without granting any new permit or writing any authorization checks explicitly.

Couple of more things to highlight before we conclude:

  1. The contactId parameter in the List operation will also be authorized if it’s a non-null value. On the other hand, if it’s null, presence of PossessesPermissionCodeAttribute will make sure that mere possession of the IndexInteraction permission is enough to authorize the request.
  2. We don’t use PossessesPermissionCodeAttribute on Add operation because it has ContactId identifier property as a required field.

a. Add the following code as a view Index.cshtml in the folder Views\Interaction

@using SuperCRM.Infrastructure
@{
    ViewBag.Title = "Interactions";
    ViewBag.jTableStyle = "~/css/jtable/themes/metro/blue/jtable.min.css";
}

<section>
    <h1> @ViewBag.Title </h1>
    <hr />
    <div id="interactionContainer" class="jtable-div"></div>
</section>

@section scripts
{
    <script src="~/js/jquery-ui.min.js"></script>
    <script src="~/js/jtable.min.js"></script>

    <script type="text/javascript">
        $(function() {

           $('#interactionContainer').jtable({
                    title: 'Interactions',
                    paging: true,
                    sorting: false,
                    columnSelectable: false,
                    AntiForgeryToken: '@Html.AntiForgeryTokenValue()',
                    actions: {
                        listAction: '@Url.Action("List")',
                        deleteAction: '@Url.Action("Delete")',
                        createAction: '@Url.Action("Add")',
                        updateAction: '@Url.Action("Edit")'
                    },
                    fields: {
                        Id: {
                            key: true,
                            create: false,
                            edit: false,
                            list: false
                        },
                        ContactId: {
                            title: 'Contact',
                            options: '/Contact/GetAll'
                        },
                        Method: {
                            title: 'Interaction Method',
                            options: {
                                '0': 'Phone',
                                '1': 'Email',
                                '2': 'Forum',
                                '3': 'SocialMedia',
                                '4': 'EmbeddedChat',
                                '5': 'Other'
                            }
                        },
                        MethodDetails: {
                            title: 'Method Details'
                        },
                        Notes: {
                            title: 'Notes'
                        },
                        InteractionDate: {
                            title: 'Interaction Date',
                            type: 'date'
                        },
                        CreatedDate: {
                            title: 'Created Date',
                            create: false,
                            edit: false
                        },
                        CreatedByName: {
                            title: 'Created By',
                            create: false,
                            edit: false
                        }
                    }
                }).jtable('load');
        });
    </script>
}

@section cssImport
{
    <link href="~/css/jquery-ui.min.css" rel="stylesheet" type="text/css" />
    <link href="@Url.Content(ViewBag.jTableStyle)" rel="stylesheet" type="text/css" />

    <style>
        .child-opener-image {
            cursor: pointer;
        }

        .child-opener-image-column {
            text-align: center;
        }

        .jtable-dialog-form {
            min-width: 220px;
        }

            .jtable-dialog-form input[type="text"] {
                min-width: 200px;
            }
    </style>
}

b. Add the following GetAll action to ContactController. We’ll use it in listing contacts for the contact dropdown while adding an Interaction.

[HttpPost]
[PossessesPermissionCode, AuthAction("Index")]
public async Task<ActionResult> GetAll()
{
	var result = await this.dbContext.Contacts.Where(c => c.OwnerId == this.UserService.CurrentUser.OwnerUserId)
		.OrderBy(p => p.Name)
		.Select(x => new { Value = x.Id, DisplayText = x.Name })
		.ToListAsync();

	return Json(new { Options= result, Result="OK" });
}

c. Add the following code as a partial view Shared\_Interactions.cshtml. This is for showing interactions of a contact in a popup.

<div class="modal fade" id="interactionsModal" tabindex="-1">
    <div class="modal-dialog modal-lg" role="document">
        <div class="modal-content">
            <div class="modal-header bg-primary">
                <h5 class="modal-title" id="gridModalLabel"></h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <div class="container-fluid">
                    <div class="row">
                        <div id="interactionsContainer" class="jtable-div"></div>
                    </div>
                </div>
            </div>
            <div class="modal-footer">
                <button type="button" id="btnClose" class="btn btn-secondary" data-dismiss="modal">Close</button>
            </div>
        </div>
    </div>
</div>

d. Now include the above _Interactions partial view into the Contact/Index.cshtml view:

...
<section>
    <h1> @ViewBag.Title </h1>
    <hr />
    <div id="contactContainer" class="jtable-div"></div>
</section>

@Html.Partial("_Interactions")
...

e. Also add a new jtable column Actions after CreatedByName to the Contacts grid in Contact\Index.cshtml as shown below:

...
$('#contactContainer').jtable({
                        
							....
							 CreatedByName: {
                                title: 'Created By',
                                create: false,
                                edit: false
                            },
							Actions: {
									title: '',
									display: function(data) {
										return `<a href='#' data-toggle='modal' data-keyboard='true' data-target='#interactionsModal' onclick='listInteractions(\"${data.record.Id}\")'>View Interactions</a>`;
									}
							}
						}
                    }).jtable('load');
            });
...

f. Add the javascript function listInteractions after the Contacts grid definition in Contact\Index.cshtml as shown below. This function is being invoked in the Actions column mentioned above after passing in the appropriate ContactId.

<script type="text/javascript">
...
function listInteractions(contactId) {
    if ($('#interactionsContainer').children().length > 0) {
        $('#interactionsContainer').jtable('destroy');
    }

    $('#interactionsContainer').jtable({
        title: 'Interactions',
        paging: true,
        sorting: false,
        columnSelectable: false,
        AntiForgeryToken: '@Html.AntiForgeryTokenValue()',
        actions: {
            listAction: `/interaction/list?contactId=${contactId}`
        },
        fields: {
            Id: {
                key: true,
                create: false,
                edit: false,
                list: false
                },
            Method: {
                    title: 'Interaction Method',
                    options: {
                        '0': 'Phone',
                        '1': 'Email',
                        '2': 'Forum',
                        '3': 'SocialMedia',
                        '4': 'EmbeddedChat',
                        '5': 'Other'
                    }
                },
            MethodDetails: {
                title: 'Method Details'
            },
            Notes: {
                title: 'Notes'
            },
            InteractionDate: {
                title: 'Interaction Date'
            },
            CreatedDate: {
                title: 'Created Date'
            },
            CreatedByName: {
                title: 'Created By'
            }
        },
        messages: {
            noDataAvailable: 'No interactions have been registered for the contact yet.'
        }
    }).jtable('load');
}
 </script>

g. Add the Interactions menu link in the Shared/_Layout.cshtml for the authenticated users.
After this, you can run the app and verify all the changes of this step.

...
@if (Context.UserService().IsAuthenticated)
{
    ...
    <li class="nav-item @Html.IsSelected("Index", "Interaction")">
        <a class="nav-link" asp-area="" asp-controller="Interaction" asp-action="Index">Interactions</a>
    </li>
    ...
}
...

Evaluation limit encountered

Note

At any point in time while working on the tutorial sample, if you face error stating that you’ve reached or exceeded number of operations allowed during evaluation, you can solve the issue by generating a trial key that allows far greater number of operations than what the default restrictions do.

Tell me more

For more complex real-world examples of ADA in action, you can go through the SuperFinance tutorial. SuperFinance is a digital banking software as a service (SaaS) prototype with multiple levels of domain entities and many user roles, requiring even greater access-control options which is served very well by ADA.

Again, if you haven’t already, we recommend you go through both the design guide and the how-to article on ADA. Those two are quite comprehensive and give you deep understanding of activity data authorization concepts and tools that ASK offers.

Check out the ASP.NET Core Policy Authorization vs. ASPSecurityKit Activity-Data Authorization (ADA) guide (or the video that accompanies it) if interested in understanding the benefits ADA provides over built-in ASP.NET authorization options.

Download the sample

Refer the working sample project for this step of the tutorial on GitHub. Download the tutorial repo containing a stand-alone sample project for each step to try it locally on your PC.

Live demo

Visit https://SuperCRM-Mvc.ASPSecurityKit.net to play with a live demo built based on this tutorial.