Step 5: Build multi-tenant interaction recording authorized using activity data authorization (ADA)
In this article
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:
- 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 benull
in such case and hence we’re querying by theOwnerUserId
. - 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:
- The
contactId
property in theGetInteractions
operation will also be authorized if it’s a non-null value. On the other hand, if it’snull
, presence of PossessesPermissionCodeAttribute will make sure that mere possession of theGetInteractions
permission is enough to authorize the request. - We don’t use PossessesPermissionCodeAttribute on
CreateInteraction
operation because it hasContactId
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.