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 ServiceStack 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 APIs to let consumers 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 service model

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

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

using System;
using System.Runtime.Serialization;
using SuperCRM.DataModels;

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

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

		[DataMember]
		public InteractionMethod Method { get; set; }

		[DataMember]
		public string MethodDetails { get; set; }

		[DataMember]
		public string Notes { get; set; }

		[DataMember]
		public DateTime InteractionDate { get; set; }

		[DataMember]
		public DateTime CreatedDate { get; set; }

		[DataMember]
		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 service 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

The DTOs and validators

Add the following code as Interactions.cs into the folder Request\Interactions:

using System;
using System.Runtime.Serialization;
using ASPSecurityKit.ServiceStack;
using ServiceStack;
using SuperCRM.Models;
using SuperCRM.Response;

namespace SuperCRM.Request.Interactions
{
	[DataContract]
	[Route("/interactions", "POST")]
	public class CreateInteraction : Interaction, IReturn<BaseRecordResponse<Interaction>>
	{
	}

	[DataContract]
	[Route("/interactions/{InteractionId}", "DELETE")]
	public class DeleteInteraction : IReturn<BaseResponse>
	{
		[DataMember]
		public Guid InteractionId { get; set; }
	}

	[DataContract]
	[Route("/interactions/{Id}", "PUT")]
	public class EditInteraction : Interaction, IReturn<BaseRecordResponse<Interaction>>
	{
	}

	[DataContract]
	[Route("/interactions", "GET")]
	[Route("/contacts/{ContactId}/interactions", "GET")]
	[PossessesPermissionCode]
	public class GetInteractions : PageRequestBase, IReturn<BaseListResponse<Interaction>>
	{
		[DataMember]
		public Guid? ContactId { get; set; }
	}
}

Add the following code as InteractionsValidator.cs into the folder Validators\Interactions:

using ServiceStack.FluentValidation;
using SuperCRM.Models;
using SuperCRM.Request.Interactions;

namespace SuperCRM.Validators.Interactions
{
	public class InteractionValidator : AbstractValidator<Interaction>
	{
		public InteractionValidator()
		{
			RuleFor(x => x.ContactId).NotEmpty();
			RuleFor(x => x.MethodDetails).MaximumLength(256);
		}
	}

	public class CreateInteractionValidator : AbstractValidator<CreateInteraction>
	{
		public CreateInteractionValidator(IValidator<Interaction> interactionValidator)
		{
			RuleFor(x => x).SetValidator(interactionValidator);
		}
	}

	public class EditInteractionValidator : AbstractValidator<EditInteraction>
	{
		public EditInteractionValidator(IValidator<Interaction> interactionValidator)
		{
			RuleFor(x => x.Id).NotEmpty();
			RuleFor(x => x).SetValidator(interactionValidator);
		}
	}
}

The operations

Add the following code as InteractionService.cs into the folder ServiceInterface:

using System;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using ASPSecurityKit;
using ASPSecurityKit.ServiceStack;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
using SuperCRM.DataModels;
using SuperCRM.Models;
using SuperCRM.Request.Interactions;
using SuperCRM.Response;

namespace SuperCRM.ServiceInterface
{
    public class InteractionService : ServiceBase
    {
        private readonly AppDbContext dbContext;
        private readonly IMapper mapper;

        public InteractionService(IUserService<Guid, Guid, DbUser> userService,
            IServiceStackSecuritySettings securitySettings, AppDbContext dbContext, IConfig config, IMapper mapper)
            : base(userService, securitySettings, config)
        {
            this.dbContext = dbContext;
            this.mapper = mapper;
        }

        public async Task<BaseRecordResponse<Interaction>> Post(CreateInteraction request)
        {
            var entity = mapper.Map<DbInteraction>(request);
            entity.CreatedById = this.UserService.CurrentUserId;
            this.dbContext.Interactions.Add(entity);
            await this.dbContext.SaveChangesAsync();
            return Ok(mapper.Map<Interaction>(entity));
        }

        public async Task<BaseResponse> Put(EditInteraction request)
        {
            var entity = await this.dbContext.Interactions.FindAsync(request.Id);
            if (entity == null)
                return Error(OpResult.DoNotExist, "Interaction not found.");

            mapper.Map(request, entity);
            await this.dbContext.SaveChangesAsync();
            return Ok(mapper.Map<Interaction>(entity));
        }

        public async Task<BaseResponse> Delete(DeleteInteraction request)
        {
            var entity = await this.dbContext.Interactions.FindAsync(request.InteractionId);
            if (entity == null)
                return Error(OpResult.DoNotExist, "Interaction not found.");

            this.dbContext.Remove(entity);
            await this.dbContext.SaveChangesAsync();
            return Ok();
        }

        public async Task<BaseListResponse<Interaction>> Get(GetInteractions request)
        {
	        Expression <Func<DbInteraction, bool>> predicate;
            if (request.ContactId.HasValue)
                predicate = i => i.ContactId == request.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(request.StartIndex).Take(request.PageSize)
		            .AsQueryable()
		            .ProjectTo<Interaction>(mapper.ConfigurationProvider)
		            .ToListAsync()
            };

            return Ok(result.ThisPage, result.Total);
        }
    }
}

The GetInteractions operation takes an optional ContactId as part of the request. This is so consumers can build two kinds of UIs for interactions:

  1. Stand-alone: 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 could 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 Create and Edit operations, after checking that we’ve got a valid model, we use AutoMapper to transfer data from the service 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 PageRequestBase, BaseListResponse, BaseRecordResponse, Error, Ok etc. here. These are elements part of ServiceStack template you’ve got. These utilities streamline secure and rapid development of business APIs. Starter and Premium source packages have even more extensive version of these routines, out of which a basic form has been extracted into the ServiceStack 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
GetInteractions GetInteractions
CreateInteraction CreateInteraction
EditInteraction EditInteraction
DeleteInteraction DeleteInteraction

Now let’s define its implied permissions:

Permission Implied
CreateInteraction GetInteractions
EditInteraction CreateInteraction
DeleteInteraction EditInteraction
GetContacts GetInteractions
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 – get, 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 API 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[]
	{
		"('GetInteractions', 'Interaction', 'List Interactions', 0)", // general permission
		"('CreateInteraction', 'Interaction', 'Add new Interaction', 0)", // general permission
		"('EditInteraction', 'Interaction', 'Modify Interaction', 1)", // instance permission
		"('DeleteInteraction', 'Interaction', 'Delete Interaction', 1)", // instance permission
	};

	var impliedPermissions = new[]
	{
		"('CreateInteraction', 'GetInteractions')",
		"('EditInteraction', 'CreateInteraction')",
		"('DeleteInteraction', 'EditInteraction')",
		"('GetContacts', 'GetInteractions')",
		"('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.

After this, you can run the app and verify all the changes of this step.

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 (on ASP.NET Core MVC).

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 property in the GetInteractions 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 GetInteractions permission is enough to authorize the request.
  2. We don’t use PossessesPermissionCodeAttribute on CreateInteraction operation because it has ContactId identifier property as a required field.

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 (currently targeting only ASP.NET Core MVC). 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-ServiceStack.ASPSecurityKit.net to play with a live demo built based on this tutorial.