×
Back to book

C#Bot Custom Security

In this article we will customise the security of a C#Bot application to match some requirements that cannot be input to the security model.

Introduction and Modelling

For this article we are going to be referring to the following model.

The entity model that contains an employee entity, a project entity and department entity

From this model we can see that a department has many employees as well as many projects.

The requirement we are going to be fulfilling in this article is the following:

An employee can only view or edit projects owned by their department.

To start off with implementing this requirement we can set up a minimal security model on the platform.

The security diagram. This shows the employee entity with limited access.

How C#Bot Security Works

The security in C#Bot is entirely model based and this can be seen from the class structure inside the project. To see this we can take a look at the serverside C# model for the department entity. This can be found in serverside/src/Models/ProjectEntity/ProjectEntity.cs. In this file there is the following block of code:

[NotMapped]
public IEnumerable<IAcl> Acls => new List<IAcl>
{
    new EmployeeProjectEntity(),
    // % protected region % [Add any further ACL entries here] off begin
    // % protected region % [Add any further ACL entries here] end
};

This is a collection of ACL classes that determine the security in place for the entity. We can take a look at the EmployeeDepartmentEntity ACL class to take a deeper dive into the workings of security. This file can be found at serverside/src/Security/Acl/EmployeeProjectEntity.cs.

For now we are going to take a look at the top of the class. The rest of the methods in the class follow a similar pattern.

public class EmployeeProjectEntity : IAcl
{
    public string Group => "Employee";
    public bool IsVisitorAcl => false;

    public bool GetCreate(User user, IEnumerable<IAbstractModel> models, SecurityContext context)
    {
        // % protected region % [Override create rule contents here here] off begin
        return false;
        // % protected region % [Override create rule contents here here] end
    }

    public Expression<Func<TModel, bool>> GetReadConditions<TModel>(User user, SecurityContext context)
        where TModel : IOwnerAbstractModel, new()
    {
        // % protected region % [Override read rule contents here here] off begin
        return model => true;
        // % protected region % [Override read rule contents here here] end
    }

To explain the key elements of the functionality of this class:

  • The Group field is used to determine whether the ACL should be used to calculate the security rules for the user that is performing the operation. If the user is in the specified group then the ACL shall be evaluated.
  • The IsVisitorAcl field is used to determine whether this is a security rule that should apply to non logged in users to the system. It is important to note that any visitor ACLs shall also apply to any logged in users as well.
  • The GetCreate function is used to evaluate whether the provided IEnumerable of models can be created. The security model is setup to prevent employees from creating projects, thus this function will return false in this scenario.
  • The GetReadConditions function is used to determine a filter for data to be read from the database. This returns an expression that is passed into an Entity Framework Where condition. This must return a filter since we do not know ahead of time what data is going to be fetched from the database.

There are other functions for the update and delete operations that follow the same patterns as well.

It is important to note that the security service in the application will collect all relevant ACLs for the user performing the operation and combine them with a logical OR statement. This means that the most permissive ACL will take precedence.

Customising ACLs

Now we have an understanding of how the ACLs work we can modify them to meet our requirement listed before. To recap

An employee can only view or edit projects owned by their department.

To do this we must edit the ACL class to check if the DepartmentId foreign key field on the employee entity matches the DepartmentId foreign key field on the Project entity.

Customising Read Security

We will start off on the implementation for the read operation. An easy (but not compiling solution!) would be to do something like this.

public Expression<Func<TModel, bool>> GetReadConditions<TModel>(User user, SecurityContext context)
    where TModel : IOwnerAbstractModel, new()
{
    // % protected region % [Override read rule contents here here] on begin
    if (typeof(TModel) == typeof(ProjectEntity) && user is EmployeeEntity employee)
    {
        return model => model.DepartmentId == employee.DepartmentId;
    }
    return model => false;
    // % protected region % [Override read rule contents here here] end
}

However this doesn't work for 2 different reasons. The first being that checking the typeof(TModel) is performed at runtime and does not assist the compile time in asserting the value of the generic. The second reason is that the function must return an expression that takes a TModel as an argument and due to language limitations this class can't be specialised.

To work around these issues we must instead build the expression at runtime dynamically. This is done by creating an Expression Tree.

public Expression<Func<TModel, bool>> GetReadConditions<TModel>(User user, SecurityContext context)
    where TModel : IOwnerAbstractModel, new()
{
    // % protected region % [Override read rule contents here here] on begin
    // Check if the provided types are not what we care about and return.
    if (typeof(TModel) != typeof(ProjectEntity) || !(user is EmployeeEntity employee))
    {
        return model => false;
    }

    // Create the expression parameter field and constant for the employee user
    // Due to the check above, the param will be of type ProjectEntity
    var param = Expression.Parameter(typeof(TModel));
    var userConstant = Expression.Constant(employee);

    // Extract the DepartmentId fields from the project parameter and the user
    var queryField = Expression.PropertyOrField(param, "DepartmentId");
    var userField = Expression.PropertyOrField(userConstant, "DepartmentId");

    // Return an expression that asserts that the both DepartmentId fields are equal
    return Expression.Lambda<Func<TModel, bool>>(
        Expression.Equal(queryField, userField), 
        param);
    // % protected region % [Override read rule contents here here] end
}

This code performs the exact same action as non functional code above however now it has no errors. As we can see this will create an expression that extracts fields from the runtime type as opposed to the compile time type, and therefore will not throw any compile errors.

Customising Update Security

With the update there is 2 functions that we must account for. These are the GetUpdateConditions and GetUpdate methods in the ACL.

The implementation for GetUpdateConditions is identical to what was done for the read conditions. It is recommended to abstract the logic of the read ACL into it's own function so there does not need to be duplicated code for both read and update.

The implementation for GetUpdate is much simpler compared to the conditional statement since it does not require any runtime expression creation to validate the security. An example implementation of the problem would be as follows.

public bool GetUpdate(User user, IEnumerable<IAbstractModel> models, SecurityContext context)
{
    // % protected region % [Override update rule contents here here] on begin
    // Ensure the type of the user
    if (!(user is EmployeeEntity employee))
    {
        return false;
    }

    // Get the existing list of projects from the database.
    var projects = models
        .Where(m => m.GetType() == typeof(ProjectEntity))
        .Cast<ProjectEntity>()
        .ToList();
    var projectIds = projects.Select(p => p.Id).ToList();
    var existingProjects = context.DbContext.ProjectEntity
        .AsNoTracking()
        .Where(x => projectIds.Contains(x.Id))
        .ToList();

    // Loop over each model and if it is a project without a matching department id then fail
    // the security validation
    foreach (var project in projects)
    {
        var existingProject = existingProjects.FirstOrDefault(p => p.Id == project.Id);
        if (existingProject?.DepartmentId != employee.DepartmentId)
        {
            return false;
        }
    }
    return true;
    // % protected region % [Override update rule contents here here] end
}

This will ensure that any update operations by employees will only succeed if they are in the same department as the project.