SuperFinance Digital Banking SaaS: Step-2 – Setup and Manage Banks, Branches, Accounts, and Staff
In this article
Introduction
As part of this tutorial, we’re building SuperFinance – a digital banking SaaS using ASPSecurityKit. In prior steps, we’ve learned about SF’s features, type of users, security mechanisms to be employed, data models, and permissions.
In this step, we’ll build the workflows or interfaces for bank employees to setup and manage bank, branches, accounts, and different types of staff users. We’ll also make use of various features of activity-data authorization (ADA) to implement exactly the authorization controls we need for several different scenarios we’ve got.
Important information
- Every feature we build may require multiple things including view model, controller, and manager methods. We’ll list out each such thing (linking to its source code) but would only talk about things for which we have something to convey. We hope the rest is easy to follow – although if that’s not the case, you can give your valuable feedback at the bottom of this page.
- We’ll leverage the sources that came with the Premium package we installed in step-1 but wouldn’t link to them, because we’re not creating them for this tutorial. By looking at the code it’d be apparent that we’re using them.
- Much of this source code is compiled into an demo assembly as source packages require a purchase.
- We continue to write the business logic in managers based on the architecture we’ve received from the Premium source package. The benefit of managers is that they’re agnostic of web requests, so you can use them in background jobs as well.
Loading BankId
The bankId acts as a tenant identifier in the system – we’ll need it to load bank related security policies for staff users and also to let staff users perform actions that need BankId but they shouldn’t be asked for it because every staff user is associated with only one bank ever. Therefore, we need to load the BankId upon authentication as follows.
- Define
BankId
property in IdentityAuthDetails and UserService. - Load it in IdentityRepository.LoadAdditionalData.
protected override async Task<IAuthDetails> LoadAdditionalDataAsync(DbUserSession dbSession, IdentityAuthDetails auth)
{
if (dbSession.User.UserType == UserType.Staff && auth is SFIdentityAuthDetails authDetails)
{
var owningUserId = dbSession.User.ParentId ?? dbSession.UserId;
var dbBank = await dbContext.Banks.FirstOrDefaultAsync(x => x.OwningUserId == owningUserId)
.ConfigureAwait(false);
if (dbBank != null)
{
authDetails.BankId = dbBank.Id;
}
}
return auth;
}
- Next, in OnAuthenticated event handler, set the BankId from identity details into UserService, to save it to the current user’s session store.
On-boarding Banking Institutions
SuperFinance intends to provide self-service interfaces for banking institutions to sign up and build the digital banking service for their bank.
The first step thus is on-boarding, which needs the following:
- RegisterBank view model which captures information about the bank owner user and basic bank details.
- RegisterBankAsync method in BankManager
- RegisterStaffUserAsync method in SFUserManager
- SignUp action in BankController.
- SignUp form input view.
RegisterBank View Model
RegisterBankModel
public class RegisterBankModel
{
[MaxLength(60)]
[Required]
[Display(Name = "Bank Name")]
public string Name { get; set; }
[Display(Name = "Address")]
public string Address { get; set; }
[MaxLength(100)]
[Required]
[Display(Name = "Admin Email")]
[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
[DoNotAuthorize]
public string Username { get; set; }
[Required]
[StringLength(512, MinimumLength = 6, ErrorMessageResourceName = "InvalidPasswordLength", ErrorMessageResourceType = typeof(Messages))]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
RegisterBankAsync Method in BankManager
RegisterBankAsync
public async Task<LoginResult> RegisterBankAsync(RegisterBankModel model, string verificationUrl, string contactUrl)
{
try
{
var result = await this.userManager.RegisterStaffUserAsync(
new AppUser
{
Name = "Administrator",
Username = model.Username,
Password = model.Password
},
verificationUrl, contactUrl, true);
if (result != null && result.IsSuccess)
{
var dbBank = new DbBank
{
Id = Guid.NewGuid(),
Name = model.Name,
Address = model.Address,
OwningUserId = this.userService.CurrentUserId
};
this.dbContext.Banks.Add(dbBank);
await this.permitRepository
.AddPermitAsync(dbBank.OwningUserId, SFPermissionCodes.BankOwner, dbBank.Id)
.ConfigureAwait(false);
await this.permitRepository
.AddPermitAsync(dbBank.OwningUserId, PermissionCodes.AddUser, null)
.ConfigureAwait(false);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
// reload permissions to include permit granted on the new bank entity to the owner user.
await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);
// set bankId in session store
this.userService.BankId = dbBank.Id;
}
return result;
}
catch (OpException)
{
throw;
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
if (ex.GetBaseException() is SqlException sqlEx &&
sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
{
throw new OpException(OpResult.AlreadyExists,
string.Format(SFMessages.CannotAddDuplicateBank, model.Name));
}
throw;
}
}
- In the above method, we’re doing three major things: creating the bank owner user, creating a new bank entity, and granting
BankOwner
role permit to the user on the bankId. We’re also grantingAddUser
general permit so the bank owner can add staff users and setting the BankId into the session as the new user is also logged in upon registration. - Since the bank name has to be unique across the system, we have a constraint in the DbContext for it and we’re handling the exception generated upon its violation.
RegisterStaffUserAsync Method in SFUserManager
RegisterStaffUserAsync
public async Task<LoginResult> RegisterStaffUserAsync(AppUser user, string verificationUrl, string contactUrl, bool createAuthCookie)
{
var result = await RegisterUserAsync(user, verificationUrl, contactUrl, createAuthCookie).ConfigureAwait(false);
if (result != null)
{
var dbUser = await this.dbContext.Users
.SingleOrDefaultAsync(x => x.Username == user.Username)
.ConfigureAwait(false);
dbUser.UserType = UserType.Staff;
this.userService.CurrentUser.UserType = UserType.Staff;
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
return result;
}
SFUserManager.RegisterCustomerAsync
callsUserManager.RegisterUserAsync
method to create the user and then sets theUserType
asStaff
UserManager.RegisterUser
(which came as part of the Premium package) performs series of steps including creating the user, sending a verification email and login.
SignUp in BankController
SignUp
[AllowAnonymous]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> SignUp(RegisterBankModel model)
{
if (ModelState.IsValid)
{
try
{
var result = await this.bankManager.RegisterBankAsync(
model, VerificationUrl(), ContactUrl());
if (result != null && result.IsSuccess)
{
if (this.securitySettings.MustHaveBeenVerified)
{
return RedirectWithMessage("Verify", "User",
string.Format(Messages.VerificationMailSent, this.userService.CurrentUsername), OpResult.Success);
}
return RedirectToAction("Index");
}
}
catch (OpException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
// If we got this far, something failed, redisplay form
return View(model);
}
- We’re redirecting the new user on the verification page (came with the Premium source package) to take user through the email verification workflow.
Authorization
Please note the AllowAnonymous attribute placed over the SignUp action. This attribute indicates to ASK’s security pipeline that the given action is accessible without authentication. Since SuperFinance allows banking institutions to sign up and setup bank on their own, the SignUp
action is publicly and anonymously available.
SignUp UI
Setup AccountTypes
A bank can setup number of account types it offers to its customers. Essentially there are only two kinds of accounts in SF: Investment and Loan. However, a bank usually has different interest rates for different kinds of investments or loans, for example, Personal Loan, Car Loan, Home Loan, Fixed Deposits-1 year, Fixed Deposits-3 years, and so on. AccountType helps in defining such bank products.
For this feature, we need the following:
- AccountType model.
- AccountTypeManager with all the four CRUD operations to add/edit/delete/list AccountTypes. The list method supports paging for the grid UI.
- AccountTypeController with all the four CRUD actions to add/edit/delete/list AccountTypes. The list method supports paging for the grid UI.
- AccountTypeId related ReferencesLoader.
- AccountType management grid view and menu item.
AccountType model
AccountType
public class AccountType
{
[Authorize("AccountTypeId")]
public Guid? Id { get; set; }
[Required]
[MaxLength(30)]
public string Name { get; set; }
[Required]
public double InterestRate { get; set; }
[Required]
public AccountKind Kind { get; set; }
}
- The AuthorizeAttribute tells the actual name AccountTypeId of the
AccountType.Id
during the discovery phase of data authorization. This is directly helping ASK to find AccountTypeId related ReferencesLoader to authorize Edit/etc. actions on the given AccountType instance. If you miss specifying this name, ADA will deny access to the data which is always preferred for protecting data.
AcountTypeManager
AddAccountTypeAsync
public async Task<AccountType> AddAccountTypeAsync(AccountType accountType)
{
try
{
var dbAccountType = new DbAccountType
{
Name = accountType.Name,
InterestRate = accountType.InterestRate,
Kind = accountType.Kind,
BankId = this.userService.BankId.GetValueOrDefault()
};
this.dbContext.AccountTypes.Add(dbAccountType);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
return new AccountType
{
Id = dbAccountType.Id,
Name = dbAccountType.Name,
InterestRate = dbAccountType.InterestRate,
Kind = dbAccountType.Kind
};
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
if (ex.GetBaseException() is SqlException sqlEx &&
sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
{
throw new OpException(OpResult.AlreadyExists,
string.Format(SFMessages.CannotAddDuplicateAccountType, accountType.Name));
}
throw;
}
}
EditAccountTypeAsync
public async Task<AccountType> EditAccountTypeAsync(AccountType accountType)
{
var dbAccountType = await this.dbContext.AccountTypes
.Where(m => m.Id == accountType.Id)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
if (dbAccountType == null)
{
await this.logger.WarnAsync("{0} doesn't exist", accountType.Name).ConfigureAwait(false);
throw new OpException(OpResult.DoNotExist);
}
try
{
dbAccountType.Name = accountType.Name;
dbAccountType.InterestRate = accountType.InterestRate;
dbAccountType.Kind = accountType.Kind;
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
return new AccountType
{
Id = dbAccountType.Id,
Name = dbAccountType.Name,
InterestRate = dbAccountType.InterestRate,
Kind = dbAccountType.Kind
};
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
if (ex.GetBaseException() is SqlException sqlEx &&
sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
{
throw new OpException(OpResult.AlreadyExists,
string.Format(SFMessages.CannotAddDuplicateAccountType, accountType.Name));
}
throw;
}
}
DeleteAccountTypeAsync
public async Task DeleteAccountTypeAsync(Guid id)
{
var dbAccountType = await this.dbContext.AccountTypes
.Where(m => m.Id == id)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
if (dbAccountType != null)
{
try
{
this.dbContext.AccountTypes.Remove(dbAccountType);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
throw new OpException(OpResult.DBDeletionDenied, SFMessages.CannotDeleteAccountType);
}
}
else
{
throw new OpException(OpResult.DoNotExist);
}
}
GetAccountTypesAsync
public async Task<PagedResult<AccountType>> GetAccountTypesAsync(int startIndex, int pageSize)
{
var bankId = this.userService.BankId;
var list = await this.dbContext.AccountTypes.AsNoTracking()
.Select(dummy => new
{
Total = this.dbContext.AccountTypes.Count(p => p.BankId == bankId),
ThisPage = this.dbContext.AccountTypes.Where(p => p.BankId == bankId)
.OrderBy(p => p.Name).Skip(startIndex).Take(pageSize)
.Select(x => new AccountType
{
Id = x.Id,
Name = x.Name,
InterestRate = x.InterestRate,
Kind = x.Kind
})
.ToList()
}).FirstOrDefaultAsync().ConfigureAwait(false);
return new PagedResult<AccountType>(list?.ThisPage.ToList(), startIndex, pageSize, list?.Total ?? 0);
}
- The name is unique hence in both
Add
andEdit
methods we’re handling the exception generated upon its violation.
AccountTypeController
[AuthPermission("ManageAccountType")]
public class AccountTypeController : ServiceControllerBase
{
...
}
List
[PossessesPermissionCode]
[HttpPost]
public async Task<ActionResult> List(int jtStartIndex, int jtPageSize)
{
return await SecureJsonAction(async () =>
{
var result = await this.accountTypeManager.GetAccountTypesAsync(jtStartIndex, jtPageSize);
return Json(ApiResponse.List(result.Records, result.TotalCount));
});
}
Add
[HttpPost, ValidateAntiForgeryToken]
[PossessesPermissionCode]
public async Task<ActionResult> Add(AccountType model)
{
return await SecureJsonAction(async () =>
{
if (ModelState.IsValid)
{
return Json(ApiResponse.Single(await this.accountTypeManager.AddAccountTypeAsync(model)));
}
throw new OpException(OpResult.InvalidInput);
});
}
Edit
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(AccountType model)
{
return await SecureJsonAction(async () =>
{
if (ModelState.IsValid)
{
return Json(ApiResponse.Single(await this.accountTypeManager.EditAccountTypeAsync(model)));
}
throw new OpException(OpResult.InvalidInput);
});
}
Delete
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Delete([Authorize("AccountTypeId")] Guid id)
{
return await SecureJsonAction(async () =>
{
await this.accountTypeManager.DeleteAccountTypeAsync(id);
return Json(ApiResponse.Success());
});
}
AccountTypeId Related ReferencesLoader
public async Task<List<IdReference>> AccountTypeId(Guid id)
{
return await dbContext.AccountTypes
.Where(x => x.Id == id)
.Select(x => new List<Guid> { x.BankId })
.ToIdReferenceListAsync();
}
Read the next section to learn more about it.
Authorization
- By default, ADA builds a unique permissionCode for every action, however, by decorating the controller with AuthPermissionAttribute, we’re overriding the convention and indicating to use only one permission
ManageAccountType
for all the CRUD actions as per the reasons mentioned in the activity permissions section in step-1. This helps in limiting the problem of permissions bloat. If you have a need to assign individual AccountType actions – say only create capability but not delete/edit – you can go with separate permission for each action. - You also see that we’ve decorated
Index
,List
andAdd
actions with PossessesPermissionCodeAttribute. This is because these actions have no input key data (EntityIds) to authorize against, andManageAccountType
permission has been implicitly granted againstBankId
(being implied permission of BankOwner role). Thus calls to these actions will be denied for not matching with any item in the user’s permit set. Such actions (having no EntityId expect to authorize the user on the mere basis of the user possessing the associated permission. These actions do not generally touch any sensitive existing data because nothing has been referred in the request. You may think for a moment that theList
method is indeed touching the existing data – because it’s returning the records – but it’s based on trustedBankId
value we had loaded from the database for the current user and not based on some arbitrary value sent in the request. - The AccountTypeId ReferencesLoader loads the related references (the entity hierarchy preceding the given EntityId) – BankId in this case – and only one of them is required to exist in user’s permit set for the given permission. This way you don’t have to grant a permit on every AccountTypeId to the user – just permit loaded with BankId is enough to authorize the action on all AccountTypes for that bank.
- In whichever actions AccountTypeId is mentioned, ADA will use this AccountTypeId ReferencesLoader to authorize that value.
- Usually, a ReferencesLoader also returns the EntityId itself part of the collection but here we don’t, because we won’t be granting a permit on individual AccountTypeIds.
- The
Delete
action specifies AuthorizeAttribute forid
param – this is exactly as we did in the model for theId
property, to specify the name – AccountTypeId – by which the ReferencesLoader is to be located.
Account Type Management UI
For the menu item, in _NavBarPartial.cshtml, we need to add the following code:
if (Context.UserService().PossessesPermission(PermissionCodes.ManageAccountType))
{
<li class="nav-item @Html.IsSelected("Index", "AccountType")">@Html.ActionLink("Account Types", "Index", "AccountType", routeValues: null, new { @class = "nav-link" })</li>
}
Setup Branches
A bank can setup as many branches as it needs to serve its customers in their vicinity. The types/logic required is similar to what we saw with AccountType, except that for each branch, a bank can (and should) manage users representing the branch manager and other branch staff who operate the day-to-day functions of that branch on behalf of the bank. While a bank owner can access data or perform an action related to the entire bank; branch staff have access to only the branch they have been appointed at.
For this feature, we need the following:
- Branch, BranchStaff and AddStaff models.
- BranchManager with all the four CRUD operations to add/edit/delete/list Branchs as well as a method to add staff. The list method supports paging for the grid UI.
- BranchController with all the four CRUD actions to add/edit/delete/list Branchs as well as a method to add staff. The list method supports paging for the grid UI.
- BranchId related ReferencesLoader and AddStaff auth definition.
- Branch management grid, add staff form view and menu item.
Models
Branch
public class Branch
{
[Authorize("BranchId")]
public Guid? Id { get; set; }
[Required]
[MaxLength(60)]
public string Name { get; set; }
[Required]
[MaxLength(16)]
public string Code { get; set; }
public string Address { get; set; }
public IList<BranchStaff> Staff { get; set; }
}
BranchStaff
public class BranchStaff
{
public Guid? Id { get; set; }
public string Name { get; set; }
public string Username { get; set; }
public string Role { get; set; }
public Guid? BranchId { get; set; }
}
AddStaffModel
public class AddStaffModel
{
[MaxLength(100)]
[Required]
[Display(Name = "Email")]
[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
public string Username { get; set; }
[Required]
[Display(Name = "Role")]
public string Role { get; set; }
public Guid? BranchId { get; set; }
public IList<string> Roles { get; set; }
}
- The
Role
property inAddStaff
above could be one of the two user roles represented by the permissionsBranchManager
andBranchStaff
. - The AuthorizeAttribute on
Branch.Id
tells the actual name (BranchId
) of the EntityId during the discovery phase of data authorization. This is directly helping ASK to find BranchId ReferencesLoader to authorize Edit/etc. actions on the given Branch instance. If you miss specifying this name, ADA will deny access to the data which is always preferred for protecting data.
BranchManager
AddBranchAsync
public async Task<Branch> AddBranchAsync(Branch branch)
{
try
{
var dbBranch = new DbBranch
{
Id = Guid.NewGuid(),
Name = branch.Name,
Code = branch.Code,
Address = branch.Address,
BankId = this.userService.BankId.GetValueOrDefault()
};
this.dbContext.Branches.Add(dbBranch);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
return new Branch
{
Id = dbBranch.Id,
Name = dbBranch.Name,
Code = dbBranch.Code,
Address = dbBranch.Address
};
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
if (ex.GetBaseException() is SqlException sqlEx &&
sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
{
throw new OpException(OpResult.AlreadyExists,
string.Format(SFMessages.CannotAddDuplicateBranch, branch.Name));
}
throw;
}
}
EditBranchAsync
public async Task<Branch> EditBranchAsync(Branch branch)
{
var dbBranch = await this.dbContext.Branches
.Where(m => m.Id == branch.Id)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
if (dbBranch == null)
{
await this.logger.WarnAsync("{0} doesn't exist", branch.Name).ConfigureAwait(false);
throw new OpException(OpResult.DoNotExist);
}
try
{
dbBranch.Name = branch.Name;
dbBranch.Code = branch.Code;
dbBranch.Address = branch.Address;
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
return new Branch
{
Id = dbBranch.Id,
Name = dbBranch.Name,
Code = dbBranch.Code,
Address = dbBranch.Address
};
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
if (ex.GetBaseException() is SqlException sqlEx &&
sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
{
throw new OpException(OpResult.AlreadyExists,
string.Format(SFMessages.CannotAddDuplicateBranch, branch.Name));
}
throw;
}
}
DeleteBranchAsync
public async Task DeleteBranchAsync(Guid id)
{
var dbBranch = await this.dbContext.Branches
.Where(m => m.Id == id)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
if (dbBranch != null)
{
try
{
this.dbContext.Branches.Remove(dbBranch);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.logger.ErrorAsync(ex).ConfigureAwait(false);
throw new OpException(OpResult.DBDeletionDenied, SFMessages.CannotDeleteBranch);
}
}
else
{
throw new OpException(OpResult.DoNotExist);
}
}
GetBranchesAsync
public async Task<PagedResult<Branch>> GetBranchesAsync(int startIndex, int pageSize)
{
var bankId = this.userService.BankId;
var list = await this.dbContext.Branches.AsNoTracking()
.Select(dummy => new
{
Total = this.dbContext.Branches.Count(p => p.BankId == bankId),
ThisPage = this.dbContext.Branches.Where(p => p.BankId == bankId)
.OrderBy(p => p.Name).Skip(startIndex).Take(pageSize)
.Select(x => new Branch
{
Id = x.Id,
Name = x.Name,
Code = x.Code,
Address = x.Address
})
.ToList()
}).FirstOrDefaultAsync().ConfigureAwait(false);
var branches = list?.ThisPage.ToList();
if (branches != null)
{
var branchIds = branches.Select(x => x.Id).ToList();
var staffPermissionCodes = new[] { SFPermissionCodes.BranchManager, SFPermissionCodes.BranchStaff };
var branchStaff = await this.dbContext.UserPermits.Where(x =>
branchIds.Contains(x.EntityId) && staffPermissionCodes.Contains(x.PermissionCode)).Select(x =>
new BranchStaff
{
Id = x.Id,
Name = x.UserPermitGroup.User.Name,
Username = x.UserPermitGroup.User.Username,
Role = x.PermissionCode.Replace("Branch", string.Empty),
BranchId = x.EntityId
}).ToListAsync().ConfigureAwait(false);
foreach (var branch in branches)
{
branch.Staff = branchStaff.Where(x => x.BranchId == branch.Id).ToList();
}
}
return new PagedResult<Branch>(branches, startIndex, pageSize, list?.Total ?? 0);
}
AddStaffAsync
public async Task AddStaffAsync(AddStaffModel model)
{
await this.permitRepository.AddPermitAsync(model.Username,
model.Role.Equals("Manager") ? SFPermissionCodes.BranchManager : SFPermissionCodes.BranchStaff,
model.BranchId).ConfigureAwait(false);
}
- The name is unique hence in both
Add
andEdit
methods we’re handling the exception generated upon its violation. - In
AddStaff
, we’re granting the given role to an existing user; The actual user (employee) is added via the user management interface. Please note that the role permission is granted on aBranchId
which makes sure that the user has access to only that branch.
BranchController
[AuthPermission("ManageBranch")]
public class BranchController : ServiceControllerBase
{
...
}
List
[HttpPost]
[PossessesPermissionCode]
public async Task<ActionResult> List(int jtStartIndex, int jtPageSize)
{
return await SecureJsonAction(async () =>
{
var branches = await this.branchManager.GetBranchesAsync(jtStartIndex, jtPageSize);
return Json(ApiResponse.List(branches.Records, branches.TotalCount));
});
}
Add
[HttpPost, ValidateAntiForgeryToken]
[PossessesPermissionCode]
public async Task<ActionResult> Add(Branch model)
{
return await SecureJsonAction(async () =>
{
if (ModelState.IsValid)
{
return Json(ApiResponse.Single(await this.branchManager.AddBranchAsync(model)));
}
throw new OpException(OpResult.InvalidInput);
});
}
Edit
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(Branch model)
{
return await SecureJsonAction(async () =>
{
if (ModelState.IsValid)
{
return Json(ApiResponse.Single(await this.branchManager.EditBranchAsync(model)));
}
throw new OpException(OpResult.InvalidInput);
});
}
Delete
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Delete([Authorize("BranchId")] Guid id)
{
return await SecureJsonAction(async () =>
{
await this.branchManager.DeleteBranchAsync(id);
return Json(ApiResponse.Success());
});
}
AddStaff
public ActionResult AddStaff([Authorize("BranchId")] Guid id)
{
var model = new AddStaffModel
{
BranchId = id
};
return View(PopulateAddStaffModel(model));
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> AddStaff(AddStaffModel model)
{
if (ModelState.IsValid)
{
try
{
await this.branchManager.AddStaffAsync(model);
return RedirectToAction("Index");
}
catch (OpException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
return View(PopulateAddStaffModel(model));
}
BranchId ReferencesLoader
public async Task<List<IdReference>> BranchId(Guid id)
{
return await dbContext.Branches
.Where(x => x.Id == id)
.Select(x => new List<Guid> { x.Id, x.BankId })
.ToIdReferenceListAsync();
}
Read the authorization section to learn more about it.
AddStaff AuthDefinition
public async Task<AuthResult> AddStaffAsync(AddStaffModel model, string permissionCode)
{
var result = await this.entityIdAuthorizer.IsAuthorizedAsync("EditUser", new { model.Username });
if (result.IsSuccess)
{
return await this.entityIdAuthorizer.IsAuthorizedAsync(permissionCode, new { model.BranchId });
}
return result;
}
Read the next section to learn more about it.
Authorization
- In BranchController also, we’re overriding the default conventions by specifying the
ManageBranch
permissionCode for CRUD actions in this controller, as we did in case of AccountType. - You also see that we’ve decorated
Index
,List
andAdd
actions with PossessesPermissionCodeAttribute. This is because of the same reason we had mentioned for similar actions in AccountType. TheManageBranch
permission is an instance permission and thus requires an EntityId while these actions do not have any EntityId in the request. TheBankId
we’re using in the Add/List methods is a trusted value we had loaded from the database for the current user and not based on some arbitrary value sent in the request. - The AddStaff auth definition is written to implement custom authorization logic for the associated action because we need to authorize the
Username
against a different permissionCode –EditUser
. You might have observed use of IEntityIdAuthorizer rather than IUserService to authorizeBranchId
andUsername
in the auth definition, the reason is to leverage the related references feature while authorizing these EntityIds. - The BranchId ReferencesLoader loads the related references (the entity hierarchy preceding the given EntityId) –
BankId
andBranchId
in this case – and only one of them is required to exist in user’s permit set for the given permission. This way you don’t have to grant a permit on everyBranchId
to the user – just permit loaded withBankId
is enough to authorize the action on all branches for that bank (although you can perfectly do that if there’s a requirement as we’re returning bothBranchId
andBankId
– either of them needs to match in user’s permit set!) - The
Delete
action specifies AuthorizeAttribute forid
param – this is exactly as we did in the model for theId
property, to specify the name –BranchId
– by which the ReferencesLoader is to be located.
Branch Management UI
Setup and Manage Bank Accounts
As part of this feature, we’ll build interfaces to let staff create customers and their bank accounts and change account status. Again, the branch employees (including the manager) have only access to bank accounts related to their branch.
For this feature, we need the following:
- Account and CreateAccount models.
- AccountManager with following methods: CreateAccountAsync, GetAllAccountsAsync, GetAccountStatusesAsync, ChangeAccountStatusAsync. The GetAllAccountsAsync method supports paging for the grid UI.
- Additionally, we need
AddCustomerUserAsync
in SFUserManager,GetBranchesAsync
in BranchManager, andGetAccountTypesAsync
in AccountTypeManager. - We also need transactionManager for inserting initial deposit (investment account) or withdrawal (loan account) transaction, but we’ll talk about it in the next step.
- ManageAccountController with actions to create, list and change status of accounts. The list method supports paging for the grid UI.
- AccountId related ReferencesLoader.
- Account management grid, create account form, change status form views and menu item.
Models
Account
public class Account
{
public Guid? Id { get; set; }
public string AccountNumber { get; set; }
public string IdentityNumber { get; set; }
public AccountStatus Status { get; set; }
public string Reason { get; set; }
public string AccountType { get; set; }
public AccountKind AccountKind { get; set; }
public string Branch { get; set; }
public string Name { get; set; }
public bool IsOwnAccount { get; set; }
public double Balance { get; set; }
public DateTime CreatedDate { get; set; }
}
CreateAccountModel
public class CreateAccountModel
{
[MaxLength(60)]
[Required]
[Display(Name = "Name")]
public string Name { get; set; }
[MaxLength(100)]
[Required]
[Display(Name = "Email")]
[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
[DoNotAuthorize]
public string Username { get; set; }
[MaxLength(24)]
[Required]
[Display(Name = "Identity Number")]
public string IdentityNumber { get; set; }
[Required]
[Display(Name = "Amount")]
public double Amount { get; set; }
public Guid? BranchId { get; set; }
[Required]
[Display(Name = "Account Type")]
[DoNotAuthorize]
public Guid AccountTypeId { get; set; }
[Display(Name = "Nominee Email")]
[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
[DoNotAuthorize]
public string NomineeUsername { get; set; }
public IList<Branch> Branches { get; set; }
public IList<AccountType> AccountTypes { get; set; }
}
- Since customers in SuperFinance do not belong to a specific bank, we need not authorize their references in create account action, hence using the DoNotAuthorize attribute on both
Username
andNomineeUsername
inCreateAccountModel
. - The AuthorizeAttribute on
Account.Id
tells the actual name (AccountId
) of the EntityId to help in locating the AccountId ReferencesLoader while authorizing Edit/etc. actions on the given Account instance, as we’ve seen in prior sections.
AddCustomerUserAsync in SFUserManager
AddCustomerUserAsync
public async Task<AppUser> AddCustomerUserAsync(AppUser user, string verificationUrl, string contactUrl)
{
var result = await AddUserAsync(user, verificationUrl, contactUrl).ConfigureAwait(false);
if (result != null)
{
await AddCustomerPermitsAsync(user.Username).ConfigureAwait(false);
await SendCustomerWelcomeMailAsync(result, user.Password, contactUrl).ConfigureAwait(false);
// reload permissions to include permission on newly added user entity.
await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);
}
return result;
}
private async Task AddCustomerPermitsAsync(string username)
{
var dbUser = await this.dbContext.Users
.Where(x => x.Username == username)
.Include(x => x.MultiFactors)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
dbUser.UserType = UserType.Customer;
dbUser.ParentId = null;
dbUser.MultiFactors.First().Enabled = false;
var dbUserInvites = await this.dbContext.UserInvitations
.Where(x => x.EmailAddress == dbUser.Username)
.ToListAsync()
.ConfigureAwait(false);
foreach (var invite in dbUserInvites)
{
var dbNominee = new DbAccountNominee
{
Id = Guid.NewGuid(),
AccountId = invite.AccountId,
NomineeUserId = dbUser.Id
};
this.dbContext.AccountNominees.Add(dbNominee);
invite.UserId = dbUser.Id;
await this.permitRepository
.AddPermitAsync(dbUser.Id, SFPermissionCodes.AccountNominee, invite.AccountId)
.ConfigureAwait(false);
}
await this.permitRepository
.AddPermitAsync(dbUser.Id, SFPermissionCodes.Customer, null)
.ConfigureAwait(false);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
- The
AddCustomerUserAsync
sets up the customer user, grants it theCustomer
role permission and alsoAccountNominee
role permission(s) for the pending invitation(s) (we’ll talk about it in the next step), and sends out a welcome email. - The password for new customer isn’t specified by the bank staff; it’s randomly generated by AddUser and sent via welcome email to the user. The password is marked as expired and thus user is required to change it upon login.
- The
Customer
permit is granted withEntityId
asnull
because it’s a general permit and implies such actions as open account only applicable to customer users.
GetBranchesAsync in BranchManager and GetAccountTypesAsync in AccountTypeManager
GetBranchesAsync
public async Task<List<Branch>> GetBranchesAsync(Guid? bankId, IList<Guid> branchIds)
{
var dbBranches = dbContext.Branches.Where(p => p.BankId == bankId);
if (branchIds != null && branchIds.Any())
{
dbBranches = dbBranches.Where(x => branchIds.Contains(x.Id));
}
return await dbBranches.OrderBy(p => p.Name)
.Select(x => new Branch
{
Id = x.Id,
Name = x.Name,
Code = x.Code,
Address = x.Address
})
.ToListAsync().ConfigureAwait(false);
}
GetAccountTypesAsync
public async Task<List<AccountType>> GetAccountTypesAsync(Guid? bankId, AccountKind? kind)
{
var dbAccountTypes = this.dbContext.AccountTypes.Where(x => x.BankId == bankId);
if (kind != null)
{
dbAccountTypes = dbAccountTypes.Where(x => x.Kind == kind);
}
return await dbAccountTypes.OrderBy(p => p.Name)
.Select(x => new AccountType
{
Id = x.Id,
Name = x.Name,
InterestRate = x.InterestRate,
Kind = x.Kind
})
.ToListAsync().ConfigureAwait(false);
}
- These methods return all the records because we need to populate dropdowns with them so no paging.
AccountManager
CreateAccountAsync
public async Task<Account> CreateAccountAsync(CreateAccountModel model, string verificationUrl, string contactUrl, string registerUrl, string loginUrl)
{
var dbUser = await this.dbContext.Users
.SingleOrDefaultAsync(x => x.Username == model.Username).ConfigureAwait(false);
var userId = dbUser?.Id;
if (dbUser == null)
{
// leaving password empty so a random password is generated and marked as expired so user is forced to change it upon login
var result = await this.userManager.AddCustomerUserAsync(
new AppUser
{
Name = model.Name,
Username = model.Username
},
verificationUrl, contactUrl);
userId = result.Id;
}
else if (dbUser.UserType != UserType.Customer)
{
throw new OpException(OpResult.InvalidInput, string.Format(SFMessages.NotACustomerUser, model.Username));
}
var dbAccount = new DbAccount
{
Id = Guid.NewGuid(),
Number = Guid.NewGuid().ToString("N").Substring(0, 10).ToUpper(),
Status = AccountStatus.Active,
IdentityNumber = model.IdentityNumber,
AccountTypeId = model.AccountTypeId,
BranchId = model.BranchId.GetValueOrDefault(),
OwningUserId = userId.GetValueOrDefault(),
CreatedDate = DateTime.UtcNow
};
return await AddAccountAsync(dbAccount, model.NomineeUsername, registerUrl, loginUrl, model.Amount).ConfigureAwait(false);
}
GetAccountsAsync
public async Task<PagedResult<Account>> GetAccountsAsync(int startIndex, int pageSize)
{
var dbAccounts = this.dbContext.Accounts.AsNoTracking();
if (this.userService.IsBankOwner())
{
dbAccounts = dbAccounts.Where(x => x.Branch.BankId == this.userService.BankId);
}
else
{
var branchIds = this.userService.GetEmployeeBranchIds();
dbAccounts = dbAccounts.Where(x => branchIds.Contains(x.BranchId));
}
var list = await dbAccounts
.Select(dummy => new
{
Total = dbAccounts.Count(),
ThisPage = dbAccounts
.Include(x => x.OwningUser)
.Include(x => x.Branch)
.Include(x => x.AccountType)
.Include(x => x.Nominees)
.OrderBy(p => p.Number)
.Skip(startIndex).Take(pageSize)
.Select(x => new Account
{
Id = x.Id,
AccountNumber = x.Number,
IdentityNumber = x.IdentityNumber,
Status = x.Status,
Branch = x.Branch.Name,
AccountType = x.AccountType.Name,
AccountKind = x.AccountType.Kind,
Name = x.OwningUser.Name,
CreatedDate = x.CreatedDate
})
.ToList()
}).FirstOrDefaultAsync().ConfigureAwait(false);
return new PagedResult<Account>(list?.ThisPage.ToList(), startIndex, pageSize, list?.Total ?? 0);
}
ChangeAccountStatusAsync
public async Task ChangeAccountStatusAsync(Guid id, AccountStatus status, string reason)
{
var dbAccount = await this.dbContext.Accounts.FindAsync(id).ConfigureAwait(false);
if (dbAccount != null)
{
if (status != dbAccount.Status)
{
if (suspendedAccountStatuses.Contains(dbAccount.Status))
{
await this.suspensionManager.DeleteEntityAsync(id);
}
if (suspendedAccountStatuses.Contains(status))
{
await this.suspensionManager.AddEntityAsync(new SuspendedEntity(id, "Account", status.ToString()));
}
}
dbAccount.Status = status;
dbAccount.Reason = reason;
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
else
{
throw new OpException(OpResult.DoNotExist);
}
}
GetAccountStatusesAsync
public async Task<IList<string>> GetAccountStatusesAsync(Guid accountId)
{
var accountStatus = await this.dbContext.Accounts
.Where(p => p.Id == accountId)
.Select(x => x.Status)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
return Enum.GetNames(typeof(AccountStatus)).Where(x => !x.Equals(accountStatus.ToString())).ToList();
}
- In
CreateAccountAsync
, the flow is working as follows:- Set up the customer user if it doesn’t exist.
- Create the account and grant
AccountHolder
role permission on the new account to the customer user. - If the nominee user already exists, grant it the
AccountNominee
role permission and send an email notification. - If it doesn’t, create an invitation and send an invite email.
- Create either a debit transaction if
AccountKind
isLoan
or credit transaction ifAccountKind
isInvestment
.
- In
ChangeAccountStatusAsync
, we’re updating the status and adding an entity suspension record for non-active status (or removing the existing record otherwise). We’ll talk about entity suspension rules in step4 – Advance Security controls. - In
GetAccountsAsync
, we’re looking up for all accounts across the current user’s bank if the user is BankOwner; otherwise, only based on the current (staff) user’s access to branches.
ManageAccountController
[AuthEntity("CustomerAccount")]
public class ManageAccountController : ServiceControllerBase
{
...
}
Index
[PossessesPermissionCode]
public ActionResult Index()
{
return View();
}
Create
[PossessesPermissionCode]
public async Task<ActionResult> Create()
{
var model = new CreateAccountModel();
await PopulateCreateAccountModel(model);
return View(model);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Create(CreateAccountModel model)
{
if (ModelState.IsValid)
{
try
{
var result = await this.accountManager.CreateAccountAsync(model, VerificationUrl(), ContactUrl(), RegisterUrl(), LoginUrl());
if (result != null)
{
return RedirectToAction("Index");
}
}
catch (OpException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
await PopulateCreateAccountModel(model);
return View(model);
}
ChangeStatus
[AuthPermission("ChangeStatus")]
public async Task<ActionResult> ChangeStatus(Guid accountId)
{
var model = new ChangeAccountStatusModel
{
Statuses = await this.accountManager.GetAccountStatusesAsync(accountId)
};
return View(model);
}
[HttpPost, ValidateAntiForgeryToken]
[AuthPermission("ChangeStatus")]
public async Task<ActionResult> ChangeStatus(Guid accountId, ChangeAccountStatusModel model)
{
if (ModelState.IsValid)
{
if (Enum.TryParse(model.Status, out AccountStatus status))
{
await this.accountManager.ChangeAccountStatusAsync(accountId, status, model.Reason);
return RedirectToAction("Index");
}
else
{
ModelState.AddModelError(nameof(model.Status), "Please select valid account status");
}
}
model.Statuses = await this.accountManager.GetAccountStatusesAsync(accountId);
return View(model);
}
List
[AuthAction("Index")]
[PossessesPermissionCode]
[HttpPost]
public async Task<ActionResult> List(int jtStartIndex, int jtPageSize)
{
return await SecureJsonAction(async () =>
{
var accounts = await this.accountManager.GetAccountsAsync(jtStartIndex, jtPageSize);
return Json(ApiResponse.List(accounts.Records, accounts.TotalCount));
});
}
PopulateCreateAccountModel
private async Task PopulateCreateAccountModel(CreateAccountModel model)
{
var branchIds = this.userService.PossessesPermission(SFPermissionCodes.BankOwner)
? null
: this.userService.GetEmployeeBranchIds();
model.Branches = await this.branchManager.GetBranchesAsync(this.userService.BankId, branchIds);
model.AccountTypes = await this.accountTypeManager.GetAccountTypesAsync(this.userService.BankId, null);
}
AccountId ReferencesLoader
public async Task<List<IdReference>> AccountId(Guid id)
{
return await dbContext.Accounts
.Where(x => x.Id == id)
.Select(x => new List<Guid> { x.Id, x.BranchId, x.Branch.BankId })
.ToIdReferenceListAsync();
}
Read the next section to learn more about it.
Authorization
- ADA conventionally builds a unique PermissionCode by joining the action and controller names. By decorating the controller with AuthEntityAttribute, we’re overriding the controller part of the PermissionCode as
CustomerAccount
. - With AuthAction attribute, we’re overriding the action name part of the permission here with
Index
because List action exists only to retrieve accounts data for the Index view via ajax, so only one permission is needed for both actions. - You also see that we’ve decorated
Index
,List
andCreate
(http get) actions with PossessesPermissionCodeAttribute. This is because of the same reason we had mentioned for similar actions in AccountType. These actions have noId
request input and hence they can’t be authorized against a complete permit (which includes both permission and id). TheCreate
(http post) does have aBranchId
property and hence that needs to be authorized therefore, no PossessesPermissionCodeAttribute on it. - The AccountId ReferencesLoader loads the related references (the entity hierarchy preceding the given EntityId) –
BankId
,BranchId
, andAccountId
in this case – and only one of them is required to exist in user’s permit set for the given permission. This way you don’t have to grant a permit on everyAccountId
to the staff user – just permit loaded withBankId
is enough to authorize the action on all Accounts for that bank (although you can perfectly do that if there’s a requirement as we’re also returningAccountId
, and only one of them needs to match in the user’s permit set!) - Both the
ChangeStatus
actions specify AuthorizeAttribute forid
param – this is exactly as we did in the model for theId
property, to specify the name – AccountId – by which the ReferencesLoader is to be located.
Account Management UI
Try Out the Live Demo
Visit https://superfinance.ASPSecurityKit.net to play with a live demo based on this sample.