Step 4: Build multi-tenant contact management authorized using activity data authorization (ADA)

Note

This is a continuation step of the Getting started with ASK on ASP.NET Core Web API 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, contact is the primary domain entity for our CRM. In this step we’re going to build a suite of APIs that can be consumed to build a grid-based interface to let users of our system manage the contact details of their customers and prospects.

Apart from building the CRUD operations, we need to also make sure that each user can only access and modify contacts that belong to him. ASK’s Activity-Data Authorization (ADA) does this automatically, often without needing to write any line of authorization logic.

Contact service model

It’s always a good security practice to use a separate service model rather than using the data entity to capture the request input or to output the response as json. This is because often data entities have more properties than needed for most operations – especially the relationship object properties – which can leak unintended data or even worse, modifying data never intended by the operation.

Add the following code as Contact.cs into the folder Models:

using System;
using System.ComponentModel.DataAnnotations;

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

		[MaxLength(128)]
		[Required]
		public string Name { get; set; }

		[MaxLength(15)]
		public string Phone { get; set; }

		[MaxLength(75)]
		public string Email { get; set; }

		[MaxLength(128)]
		public string Address1 { get; set; }

		[MaxLength(128)]
		public string Address2 { get; set; }

		[MaxLength(64)]
		[Required]
		public string AcquiredFrom { get; set; }

		public string Notes { get; set; }

		public DateTime CreatedDate { get; set; }

		public string CreatedByName { get; set; }
	}

	public class GetContacts : PagingModel
	{
	}
}

Automapper mapping

It’s also a good practice to automate mapping of properties between data entities and service models rather than doing by hand so that the operation logic remains tidy. Here we shall use AutoMapper for the same.

First, install the package:

install-package AutoMapper.Contrib.Autofac.DependencyInjection

Next, define the mapping profile with mapping for contact models:

(Add the following code as WebAppProfile.cs in the root folder.)

using System;
using AutoMapper;
using SuperCRM.DataModels;
using SuperCRM.Models;

namespace SuperCRM
{
	public class WebAppProfile : Profile
	{
		public WebAppProfile()
		{
			CreateMap<DbContact, Contact>()
				.ForMember(d => d.CreatedByName, o => o.MapFrom(s => s.CreatedBy.Name));

			CreateMap<Contact, DbContact>()
				.ForMember(d => d.Id, o =>
				{
					o.PreCondition((s, d, rc) => d.Id == Guid.Empty);
					o.MapFrom(s => Guid.NewGuid());
				})
				.ForMember(d => d.OwnerId, o => o.Ignore())
				.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 OwnerId and 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.

Finally, in the ASPSecurityKitConfiguration.ConfigureContainer method, register the mapper along with the mapping profile (created above) on the DI builder:

...
using AutoMapper.Contrib.Autofac.DependencyInjection;

namespace SuperCRM
{
	public class ASPSecurityKitConfiguration
	{
		...
		public static void ConfigureContainer(ContainerBuilder builder)
		{
			...
			builder.RegisterAutoMapper(typeof(WebAppProfile).Assembly);
		}
		...
	}
}

Contact CRUD operations

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

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

namespace SuperCRM.Controllers
{
    [Route("contacts")]
    public class ContactController : SiteControllerBase
    {
        private readonly AppDbContext dbContext;
        private readonly IMapper mapper;

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

        [HttpGet]
        [PossessesPermissionCode, AuthPermission]
        public async Task<BaseListResponse<Contact>> GetContacts([FromQuery] GetContacts model)
        {
            Expression<Func<DbContact, bool>> predicate = c => c.OwnerId == this.UserService.CurrentUser.OwnerUserId;

            var result = new
            {
	            Total = await this.dbContext.Contacts.CountAsync(predicate),
	            ThisPage = await this.dbContext.Contacts.Where(predicate)
		            .OrderBy(p => p.Name).Skip(model.StartIndex).Take(model.PageSize)
		            .AsQueryable()
		            .ProjectTo<Contact>(mapper.ConfigurationProvider)
		            .ToListAsync()
            };

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

        [HttpPost]
        [PossessesPermissionCode]
        public async Task<BaseResponse> Create(Contact model)
        {
            if (ModelState.IsValid)
            {
                var entity = mapper.Map<DbContact>(model);
                entity.OwnerId = this.UserService.CurrentUser.OwnerUserId;
                entity.CreatedById = this.UserService.CurrentUserId;
                this.dbContext.Contacts.Add(entity);
                await this.dbContext.SaveChangesAsync();
                return Ok(mapper.Map<Contact>(entity));
            }

            return Error();
        }

        [HttpPut]
        [Route("{Id}")]
        public async Task<BaseResponse> Edit([FromBodyAndRoute] Contact model)
        {
            if (ModelState.IsValid)
            {
                var entity = await this.dbContext.Contacts.FindAsync(model.Id);
                if (entity == null)
                    return Error(OpResult.DoNotExist, "Contact not found.");

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

            return Error();
        }

        [HttpDelete]
        [Route("{contactId}")]
        public async Task<BaseResponse> Delete(Guid contactId)
        {
            var entity = await this.dbContext.Contacts.Include(x => x.Interactions)
            .SingleOrDefaultAsync(x => x.Id == contactId);
            if (entity == null)
                return Error(OpResult.DoNotExist, "Contact not found.");

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

In the GetContacts operation, we’re querying by OwnerUserId – a property we added to the User model in the 2nd step – which represents the parent user (if current user has one which is true in cases of a team member) or the self (if current user is himself the owner). We’re also paging the result as existing/prospective contacts together could very well be in hundreds if not thousands for many businesses.

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

In the Delete operation, we load the entity and its interactions, and perform a hard delete. However, in the real system, you may rather perform a soft delete – marking the contact as deleted rather than physically deleting it.

Note

You see use of PagingModel, FromBodyAndRoute, Error etc. – these are elements part of ASK’s API template you’ve got. These utilities streamline secure and rapid development with support for paging, better model binding and proper, graceful error handling. Starter and Premium source packages have even more extensive version of these utilities, out of which a basic form has been extracted into the API template we’re using for the tutorial.

Authorization

As you can see, the above is pure business logic and there doesn’t seem to be any logic to authorize whether the user has access to an action or to a particular contact being modified. Let’s understand how ASK’s activity data authorization (ADA) manages to auto authorize these actions.

First off, activity authorization is the process of authorizing actions (activities) by looking up associated permissions in the user’s permit set. It’s different from role-based authorization in a way that you don’t hard-code roles in code. You can read more about it in this design guide.

With ADA, the default convention to determine a permission for an action is to combine names of both action and controller. It provides various options to override these default conventions. For example, we’ve used AuthPermissionAttribute on GetContacts to indicate that the action name itself is the permission for this action. Based on this understanding, we can come up with the list of permissions for contact related actions in the table below:

Operation (Activity) Permission
GetContacts GetContacts
Add AddContact
Edit EditContact
Delete DeleteContact

Once the permission is determined, ADA traverses recursively through the action’s parameters and captures every property that appears as an identifier (a key value that represents a record – also known as entityId). In this case, both Edit and Delete have contactId as identifier.

ADA then authorizes each captured identifier along with the operation’s permission by looking up for a match in the user’s permit set. Thus, if some user tries to operate upon a contact that belongs to a different user, ADA won’t find a matching permit and the request will be denied.

A permit match has to occur in full – both permissionCode and entityId must have a match – unless the user has a general permit, in which case also logically a match has occurred as user has a blanket permit for that operation regardless of the input identifier. However, sometimes you have operations – like GetContacts above – which don’t have any input identifier parameter. For such operations, mere possession of the permission – regardless of the entityId against which it’s granted – should be enough to allow access. PossessesPermissionCodeAttribute helps to indicate exactly these cases.

We also use it on Add operation here because although the input model – of type Contact – has an Id property, but that would be null during Add and there’s no other valid identifier in there that refers to an existing object. Both OwnerId and CreatedById are populated by us. Keep in mind that this is only in the case of primary entities; as we’ll see in the next step on building interaction operations, we won’t do this for Add interaction operation because it’ll have a ContactId as an input identifier. ContactId is a required property to create an interaction against a contact.

Note

Applying PossessesPermissionCodeAttribute doesn’t turn off the entityIds discovery and their authorization logic. It merely indicates that if no valid identifier value is found in the input, ASK can authorize the operation based on possesses permission check. Hence, it’s safe to use with operations that have no required but one or more optional identifier properties. (This is the default behavior which you can change by setting IgnoreEntityIdLookup to false – it increases the performance slightly for that particular case, but it’s not recommended.)

Important

The above is a simplified description of ADA’s workflow. For detailed information, you should read the how-to guide.

Where are user permits?

From the Essential package (included in the template), you get these data models related to permissioning:

  • DbPermission: Holds PermissionCode and related information. This is where you store the contact related operation permissions shown in the above table.
  • DbUserPermit: Holds a permit i.e. a collection of permission and identifier (entityId) pairs, granted to users.
  • DbImpliedPermission: Holds implied permissions i.e. mappings of permissions which are implied by other high-level permissions. With this construct, you can define roles as permissions and grant directly just like any other permission to the user.

We can grant the contact operation permissions individually but that’d be inefficient as for each customer, we’ve to add as many permit records as the number of permissions we’ve got. So, we create a role permission named Customer and then make all contact related permissions implied by it. However, we shall do it in a manner that makes a hierarchy of increasingly important permissions:

Permission Implied
AddContact GetContacts
EditContact AddContact
DeleteContact EditContact
Customer DeleteContact

We’ve created the above hierarchy because as we’d see in the [build team management] step, we’ll add a capability for team admins to grant permits to team members. And symantically it makes more sense that if someone is assigned a delete permit for example, should also be able to add or edit the records. However, this is purely our opinion; you are free to keep them separate by changing the above implied table records.

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.


The implied permissions are loaded recursively as part of user permits. For each permit, all of its implied descendants are loaded with permit’s entityId becoming the entityId of each such implied permission, and we end up having only a list of permits in the user session because ADA needs only a simple list and doesn’t care about how permits are organized in the database.

We insert the above 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 ContactPermissions

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

protected override void Up(MigrationBuilder migrationBuilder)
{
	var permissions = new[]
	{
		"('GetContacts', 'Contact', 'List Contacts', 0)", // general permission
		"('CreateContact', 'Contact', 'Add new Contact', 0)", // general permission
		"('EditContact', 'Contact', 'Modify Contact', 1)", // instance permission
		"('DeleteContact', 'Contact', 'Delete Contact', 1)", // instance permission
		"('Customer', 'User', 'Manager Customer permissions', 0)" // general permission
	};

	var impliedPermissions = new[]
	{
		"('CreateContact', 'GetContacts')",
		"('EditContact', 'CreateContact')",
		"('DeleteContact', 'EditContact')",
		"('Customer', 'DeleteContact')"
	};

	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 also need to grant the Customer permit upon new user registration, and upon login he’ll get all the CRUD permits related to contact because Customer implies all of them.

Make the below change in the SignUp action in UserController:

...
public class UserController : SiteControllerBase
{
	...
	private readonly IUserPermitRepository permitRepository;

	public UserController(IUserService<Guid, Guid, DbUser> userService, INetSecuritySettings securitySettings,
	ISecurityUtility securityUtility, IConfig config, IAuthSessionProvider authSessionProvider, IUserPermitRepository permitRepository) : base(userService, securitySettings, securityUtility,
	config)
	{
		...
		this.permitRepository = permitRepository;
	}
	...
	[HttpPost]
	[AllowAnonymous]
	[Route("sign-up")]
	public async Task<BaseResponse> SignUp(SignUp model)
	{
		...
		if (await this.UserService.CreateAccountAsync(dbUser))
		{
			await this.permitRepository.AddPermitAsync(dbUser.Id, "Customer", dbUser.Id);
			await SendVerificationMailAsync(dbUser);
			var result = await this.authSessionProvider.LoginAsync(model.Username, model.Password, false, this.SecuritySettings.LetSuspendedAuthenticate);
		}
		...
	}
	....
}

The AddPermitAsync method takes three parameters: userId (to whom the permit is to be granted), permissionCode and the entityId (the identifier against which the permission is to be granted.). For entityId parameter, we’re again specifying the user’s id – the reason of doing so we shall learn in the next section.

IPermitRepository is the component that provides methods to manage permits for user and other identities. Its implementation is part of the source package so you can change data source/ORM component as per your project needs.

It’s a good question because, the Customer permission is granted against the userId above. Since contactIds are going to be always increasing, it’s inefficient to grant and maintain permits directly on them. A better solution that ADA provides is related references, thereby you can load identifiers related to the one being authorized. Usually, this would be as per the ancestor hierarchy, though it can be anything you want to consider for authorizing the input identifier. Let’s first see the loader method for contactId.

We define this method inside AuthDefinitions\ReferencesProvider.cs which comes as part of the template.

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

So, with related references, what you’re trying to say is that to authorize contactId in Edit or Delete contact operations, we can either use its ownerId or itself. It perfectly makes sense: if you have EditContact permit on a userId, contacts owned by that user should be accessible to you.

Thus, related references is a powerful concept leveraging the increasingly privileged relationships amongst the entities in your system’s domain, and is somewhat similar to implied permissions – the higher-level identifiers implying permit on the lower-level ones.

Now we are ready to run the app and verify all the changes of this step.

Zero Trust protection: handling ‘Unauthorized’ error

If a user (or any other identity) tries to execute an operation which he’s not authorized for, the security pipeline disallows the request to proceed. An error with Unauthorized ErrorCode is written to the response as already explained in the 2nd step (revisit it to recap). As part of the error response, other useful error details are also written to the response – such as the input identifiers that failed authorization etc. – to help the legitimate caller to troubleshoot the request payload.

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 entities (banks, branches, accounts and transactions) and many user roles (bank admin, branch manager, branch staff, account holder, beneficiary (nominee)), and thus provides a wider playground for ADA to show its magic.

You should go through both the design guide and how-to article on ADA already referred on this page in several places. Those two are quite comprehensive and give you deep understanding of activity data authorization concepts and tools.

Additionally, if you’re coming with experience on the ASP.NET Core policy authorization or interested in how that compares with ADA, check out the ASP.NET Core Policy Authorization vs. ASPSecurityKit Activity-Data Authorization (ADA) guide.

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-WebApi.ASPSecurityKit.net to play with a live demo built based on this tutorial.