SuperFinance Digital Banking SaaS: Step-3 - Open accounts, Perform Transactions and Manage Nominees
In this article
Introduction
As part of this tutorial, we’re building SuperFinance - a digital banking SaaS using ASPSecurityKit. In prior steps, we’ve learnt about SF’s features, type of users, security mechanisms to be employed, data models, permissions as well as building a self-service portal for banking institutions that enables on-boarding, setting up and managing branches, staff users, accounts and customers.
In this step, we’ll build the workflows or interfaces for customers (account holder/nominee) to sign up, create accounts, add nominees, view account details (balance/transactions), perform transfers. We’ll also build cash deposit and withdrawal actions on accounts which are only available to bank staff and requires customers to visit the branch. We’ll continue to make use of various features of activity-data authorization (ADA) to implement exactly the authorization controls we need for different scenarios we’ve got.
On-boarding Customers
Similar to the bank institutions, SuperFinance intends to provide self-service interfaces for banks' customers to sign up and setup/manage their financial accounts with the bank(s) of their choice. A customer isn’t owned by any one bank; she can open account in any number of banks.
For this feature, we need the following:
RegisterCustomerUserAsync
in SFUserManager.- Updating
SignUp
in UserController. - Updating SignUp form view.
Register View Model
We’ve reused the register model came with Premium source package as is.
RegisterCustomerUserAsync Method in SFUserManager
RegisterCustomerUserAsync
public async Task<LoginResult> RegisterCustomerUserAsync(AppUser user, string verificationUrl, string contactUrl, bool createAuthCookie)
{
var result = await RegisterUserAsync(user, verificationUrl, contactUrl, createAuthCookie).ConfigureAwait(false);
if (result != null)
{
await AddCustomerPermitsAsync(user.Username).ConfigureAwait(false);
// reload permissions added as part of above call.
await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);
}
return result;
}
- The method is similar to
AddCustomerUserAsync
we had discussed in the manage accounts section - just that it doesn’t send out a welcome email.
SignUp in UserController
SignUp
[AllowAnonymous]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> SignUp(RegisterModel model)
{
if (ModelState.IsValid)
{
try
{
var result = await this.userManager.RegisterCustomerUserAsync(
new AppUser
{
Name = model.Name,
Username = model.Username,
Password = model.Password
},
VerificationUrl(), ContactUrl(), true);
if (result != null && result.IsSuccess)
{
if (this.securitySettings.MustHaveBeenVerified)
{
return RedirectWithMessage("Verify",
string.Format(Messages.VerificationMailSent, this.userService.CurrentUsername), OpResult.Success);
}
return RedirectToAction("Open", "Account");
}
}
catch (OpException ex)
{
if (GetResultForEmailServiceError(ex, false, "Index", "Home") is var result && result != null)
return result;
ModelState.AddModelError(string.Empty, ex.Message);
}
}
// If we got this far, something failed, redisplay form
return View(model);
}
Authorization
The AllowAnonymous attribute placed over the SignUp
action indicates to ASK’s security pipeline that the given action is accessible without authentication - publicly and anonymously.
SignUp UI
Open and List Bank Accounts
As part of this feature, we’ll build interfaces to let customer open new bank accounts and view list of accounts they have access to (including as a nominee). Also, a customer can only open investment accounts; the loan account creation is only possible via the staff interfaces.
For this feature, we need the following:
- Account and OpenAccount models.
OpenAccountAsync
,GetAccountsAsync
in AccountManager andListBanksAsync
in BankManager.- Additionally, we need
GetBranchesAsync
in BankManager andGetAccountTypesAsync
in AccountTypeManager which we’ve already gone through in staff manage accounts section. - AccountController with actions to open account and list accounts, account types and branches.
- Account listing grid, open account form views and menu item.
Models
We’ve already seen Account
model in the staff manage account section.
OpenAccountModel
public class OpenAccountModel
{
[Required]
[Display(Name = "Bank")]
public Guid BankId { get; set; }
public IList<Bank> Banks { get; set; }
[Required]
[Display(Name = "Branch")]
public Guid BranchId { get; set; }
[Required]
[Display(Name = "Account Type")]
public Guid AccountTypeId { get; set; }
[MaxLength(24)]
[Required]
[Display(Name = "Identity Number")]
public string IdentityNumber { get; set; }
[Display(Name = "Nominee Email")]
[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
public string NomineeUsername { get; set; }
}
- You might have noticed that there are so many EntityIds in OpenAccount model but how come they are getting authorized without us granting a permit to the customer users on them? It’s an effect of granting
OpenAccount
as a general permit, it being an implied permission of Customer permission. All these EntityIds are required to open an account by any customer on the platform, so there’s no access authorization required for them.
AccountManager
OpenAccountAsync
public async Task<Account> OpenAccountAsync(OpenAccountModel model, string registerUrl, string loginUrl)
{
var dbAccount = new DbAccount
{
Id = Guid.NewGuid(),
Number = Guid.NewGuid().ToString("N").Substring(0, 10).ToUpper(),
Status = AccountStatus.PendingApproval,
IdentityNumber = model.IdentityNumber,
AccountTypeId = model.AccountTypeId,
BranchId = model.BranchId,
OwningUserId = this.userService.CurrentUserId,
CreatedDate = DateTime.UtcNow
};
var account = await AddAccountAsync(dbAccount, model.NomineeUsername, registerUrl, loginUrl, 0)
.ConfigureAwait(false);
await this.suspensionManager.AddEntityAsync(new SuspendedEntity(account.Id.GetValueOrDefault(), "Account", account.Status.ToString()));
return account;
}
GetAccountsAsync
public async Task<List<Account>> GetAccountsAsync(Guid? userId)
{
var accounts = await this.dbContext.Accounts
.Include(x => x.OwningUser)
.Include(x => x.AccountType)
.Include(x => x.Branch)
.Include(x => x.Nominees)
.Where(x => x.OwningUserId == userId || x.Nominees.Any(x => x.NomineeUserId == userId))
.OrderBy(p => p.Number)
.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,
CreatedDate = x.CreatedDate,
IsOwnAccount = x.Nominees.All(y => y.NomineeUserId != userId)
})
.ToListAsync().ConfigureAwait(false);
return accounts;
}
- In
OpenAccountAsync
, the flow is working as follows:- Create the account and grant
AccountHolder
role permission on the new account to the current 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.
- We’re also inserting an entity suspension record because the new account is opened with
PendingApproval
status and suspension helps in limiting access to non-active accounts. We’ll talk about entity suspension rules in step4 – Advance Security controls.
- Create the account and grant
- In
GetAccountsAsync
, we’re not only retrieving accounts that the customer owns but also the accounts he has access to as a nominee.
AccountController
Open
public async Task<ActionResult> Open()
{
var model = new OpenAccountModel
{
Banks = await this.bankManager.ListBanksAsync()
};
return View(model);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Open(OpenAccountModel model)
{
if (ModelState.IsValid)
{
try
{
var result = await this.accountManager.OpenAccountAsync(model, RegisterUrl(), LoginUrl());
if (result != null)
{
return RedirectToAction("Index");
}
}
catch (OpException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
model.Banks = await this.bankManager.ListBanksAsync();
// If we got this far, something failed, redisplay form
return View(model);
}
List
[AuthAction("Index")]
[HttpPost]
[PossessesPermissionCode]
public async Task<ActionResult> List()
{
return await SecureJsonAction(async () =>
{
var accounts = await this.accountManager.GetAccountsAsync(this.userService.CurrentUserId);
return Json(ApiResponse.List(accounts, accounts.Count));
});
}
ListBranches
[AuthAction("Open")]
[HttpGet]
public async Task<ActionResult> ListBranches(Guid bankId)
{
return await SecureJsonAction(async () =>
{
var branches = await this.branchManager.GetBranchesAsync(bankId, null);
return Json(ApiResponse.List(branches, branches.Count));
});
}
ListAccountTypes
[AuthAction("Open")]
[HttpGet]
public async Task<ActionResult> ListAccountTypes(Guid bankId)
{
return await SecureJsonAction(async () =>
{
var accountTypes = await this.accountTypeManager.GetAccountTypesAsync(bankId, AccountKind.Investment);
return Json(ApiResponse.List(accountTypes, accountTypes.Count));
});
}
- There’s no paging in
List
accounts action which is intentional. We don’t expect a customer having that many accounts for paging to make sense (though he can if he so wishes!)
Authorization
- AuthAction on
ListBranches
andListAccountTypes
specifies that for these actions,Open
is the action code to build the PermissionCode. Similarly, forList
action, we’re doing the same thing - indicating to useIndex
as the action code. Index
andList
actions have PossessesPermissionCodeAttribute as there’s noid
in the request to authorize against, and hence they need not authorize against the complete permit (which includes EntityId). Neither of theOpen
actions have any attribute because it’s been granted as a general permit (without EntityId value) being an implied permission ofCustomer
.
UIs
Account Details and Transactions
As part of this feature, we’ll build interfaces to view account details and perform transactions over the account. While customers can perform transfers, staff can do cash withdrawal and deposits but neither can do both. The related permissions have already been setup as per their roles as part of step-1: Permissions section.
For this feature, we need the following:
- Transaction and Transfer models.
- TransactionManager with following methods:
CreateDepositAsync
,CreateTransferAsync
,CreateWithdrawalAsync
,GetTransactionsAsync
. - Additionally, we need
GetAccountAsync
in AccountManager. - AccountDetailsController with these actions: view account, list transactions and perform transfer, withdrawal and deposit on accounts.
- AccountId related ReferencesLoader - already gone through in staff manage accounts section.
- Account details and transfer, withdrawal and deposit form views.
Models
Transaction
public class Transaction
{
public Guid Id { get; set; }
public DateTime Date { get; set; }
public double Amount { get; set; }
public TransactionType TransactionType { get; set; }
public string Remarks { get; set; }
}
TransactionModel
public class TransactionModel
{
[Required]
[Display(Name = "Account")]
public Guid AccountId { get; set; }
[Required]
[Display(Name = "Amount")]
public double Amount { get; set; }
[Display(Name = "Remarks")]
public string Remarks { get; set; }
}
TransferModel
public class TransferModel
{
[Required]
[Display(Name = "From Account")]
[Authorize("AccountId")]
public Guid FromAccountId { get; set; }
[Required]
[Display(Name = "To Account")]
public string ToAccountNumber { get; set; }
[Required]
[Display(Name = "Amount")]
public double Amount { get; set; }
[Display(Name = "Remarks")]
public string Remarks { get; set; }
}
- The AuthorizeAttribute on
TransferModel.FromAccountId
tells the actual name AccountId of the EntityId during the discovery phase of data authorization. This is directly helping ASK to find AccountId ReferencesLoader to authorizeTransfer
action on the given Account instance. If you don’t specify it, for customers transfer will still work as the customers are granted with the AccountHolder permit on theAccountId
only andCreateTransfer
is an implied permission ofAccountHolder
role permission.
TransactionManager
CreateDepositAsync
public async Task CreateDepositAsync(TransactionModel transaction)
{
var dbTransaction = new DbTransaction
{
Id = Guid.NewGuid(),
Date = DateTime.UtcNow,
AccountId = transaction.AccountId,
Amount = transaction.Amount,
TransactionType = TransactionType.Credit,
Remarks = transaction.Remarks
};
this.dbContext.Transactions.Add(dbTransaction);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
CreateTransferAsync
public async Task CreateTransferAsync(TransferModel transfer)
{
var toAccount = await this.dbContext.Accounts.FirstOrDefaultAsync(x => x.Number == transfer.ToAccountNumber)
.ConfigureAwait(false);
if (toAccount == null)
{
throw new OpException(OpResult.DoNotExist, "Account does not exist");
}
var dbDebitTransaction = new DbTransaction
{
Id = Guid.NewGuid(),
Date = DateTime.UtcNow,
AccountId = transfer.FromAccountId,
Amount = transfer.Amount,
TransactionType = TransactionType.Debit,
Remarks = transfer.Remarks
};
var dbCreditTransaction = new DbTransaction
{
Id = Guid.NewGuid(),
Date = DateTime.UtcNow,
AccountId = toAccount.Id,
Amount = transfer.Amount,
TransactionType = TransactionType.Credit,
Remarks = transfer.Remarks
};
var dbTransfer = new DbTransfer
{
Id = Guid.NewGuid(),
CreditTransactionId = dbCreditTransaction.Id,
DebitTransactionId = dbDebitTransaction.Id,
CreatedDate = DateTime.UtcNow
};
this.dbContext.Transactions.AddRange(dbDebitTransaction, dbCreditTransaction);
this.dbContext.Transfers.Add(dbTransfer);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
CreateWithdrawalAsync
public async Task CreateWithdrawalAsync(TransactionModel transaction)
{
var dbTransaction = new DbTransaction
{
Id = Guid.NewGuid(),
Date = DateTime.UtcNow,
AccountId = transaction.AccountId,
Amount = transaction.Amount,
TransactionType = TransactionType.Debit,
Remarks = transaction.Remarks
};
this.dbContext.Transactions.Add(dbTransaction);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
GetTransactionsAsync
public async Task<PagedResult<Transaction>> GetTransactionsAsync(Guid accountId, int startIndex, int pageSize)
{
var list = await this.dbContext.Transactions.AsNoTracking()
.Select(dummy => new
{
Total = this.dbContext.Transactions.Count(p => p.AccountId == accountId),
ThisPage = this.dbContext.Transactions.Where(p => p.AccountId == accountId)
.OrderBy(p => p.Date).Skip(startIndex).Take(pageSize)
.Select(x => new Transaction
{
Id = x.Id,
Date = x.Date,
TransactionType = x.TransactionType,
Amount = x.Amount,
Remarks = x.Remarks
})
.ToList()
}).FirstOrDefaultAsync().ConfigureAwait(false);
return new PagedResult<Transaction>(list?.ThisPage.ToList(), startIndex, pageSize, list?.Total ?? 0);
}
- The
CreateDepositAsync
creates a credit transaction while theCreateWithdrawalAsync
creates a debit transaction. - The
CreateTransferAsync
is creating a debit transaction on the source account and a credit transaction on the destination account and also linking them together in the transfer model. If the destination account doesn’t exist, an error is thrown.
AccountDetailsController
Deposit
[AuthPermission("CreateDeposit")]
public ActionResult Deposit(Guid accountId)
{
var model = new TransactionModel { AccountId = accountId };
return View(model);
}
[AuthPermission("CreateDeposit")]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Deposit(TransactionModel model)
{
if (ModelState.IsValid)
{
await this.transactionManager.CreateDepositAsync(model);
return RedirectWithMessage("Index", "AccountDetails", new { accountId = model.AccountId }, SFMessages.DepositCreated, OpResult.Success);
}
return View(model);
}
Transfer
[AuthPermission("CreateTransfer")]
public ActionResult Transfer(Guid accountId)
{
var model = new TransferModel { FromAccountId = accountId };
return View(model);
}
[AuthPermission("CreateTransfer")]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Transfer(TransferModel model)
{
if (ModelState.IsValid)
{
await this.transactionManager.CreateTransferAsync(model);
return RedirectWithMessage("Index", "AccountDetails", new { accountId = model.FromAccountId }, SFMessages.TransferCreated, OpResult.Success);
}
return View(model);
}
Withdrawal
[AuthPermission("CreateWithdrawal")]
public ActionResult Withdrawal(Guid accountId)
{
var model = new TransactionModel { AccountId = accountId };
return View(model);
}
[AuthPermission("CreateWithdrawal")]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Withdrawal(TransactionModel model)
{
if (ModelState.IsValid)
{
await this.transactionManager.CreateWithdrawalAsync(model);
return RedirectWithMessage("Index", "AccountDetails", new { accountId = model.AccountId }, SFMessages.WithdrawalCreated, OpResult.Success);
}
return View(model);
}
List
[AuthAction("Index")]
[HttpPost]
public async Task<ActionResult> List(Guid accountId, int jtStartIndex, int jtPageSize)
{
return await SecureJsonAction(async () =>
{
var transactions = await this.transactionManager.GetTransactionsAsync(accountId, jtStartIndex, jtPageSize);
return JsonResponse(ApiResponse.List(transactions.Records, transactions.TotalCount));
});
}
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();
}
- Already shown in staff manage account section; repeating to make sense of authorization.
Read the next section to learn more about it.
Authorization
- AuthPermissionAttribute is applied on several actions such as
Transfer
,Withdrawal
,Deposit
etc. to specify the complete PermissionCode for those actions (overriding the default conventions). - AuthAction on
List
specifies that for this action,Index
is the action code to build the PermissionCode. - With AccountId ReferencesLoader, ADA will get
BankId
,BranchId
andAccountId
as related references. Only one of them is required to exist in user’s permit set for the permission to authorize the given action. So, a customer/nominee will get authorized based onAccountId
, branch manager/staff based onBranchId
and bank owner based onBankId
.
UIs
Account Details
@using SuperFinance.DataModels
@model AccountDetailsModel
@{
ViewBag.Title = "Details for Account " + Model.Account.AccountNumber;
ViewBag.jTableStyle = "~/Scripts/dist/jtable/themes/metro/blue/jtable.css";
}
<section class="home-content">
<h1> @ViewBag.Title </h1>
<hr />
<div class="row">
<div class="col-6">
<dl>
<dt>Account Balance</dt>
<dd>@Model.Account.Balance</dd>
</dl>
</div>
<div class="col-6">
<dl>
<dt>Holder Name</dt>
<dd>@Model.Account.Name</dd>
</dl>
</div>
</div>
<div class="row">
<div class="col-6">
<dl>
<dt>Account Type</dt>
<dd>@Model.Account.AccountType</dd>
</dl>
</div>
<div class="col-6">
<dl>
<dt>Branch</dt>
<dd>@Model.Account.Branch</dd>
</dl>
</div>
</div>
<div class="row">
<div class="col-6">
<dl>
<dt>Account Status</dt>
<dd>@Model.Account.Status</dd>
</dl>
</div>
<div class="col-6">
<dl>
<dt>Opened On</dt>
<dd>@Html.DisplayFor(m => m.Account.CreatedDate)</dd>
</dl>
</div>
</div>
<div class="btn-group">
@if (Model.Account.AccountKind == AccountKind.Investment && Context.UserService().IsAuthorized(SFPermissionCodes.CreateTransfer, Model.Account.Id.GetValueOrDefault()))
{
<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("Transfer", new { accountId = Model.Account.Id })'">Create Transfer</button>
}
@if (Context.UserService().PossessesPermission(SFPermissionCodes.CreateDeposit))
{
<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("Deposit", new { accountId = Model.Account.Id })'">Create Deposit</button>
}
@if (Context.UserService().PossessesPermission(SFPermissionCodes.CreateWithdrawal) && Model.Account.AccountKind == AccountKind.Investment)
{
<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("Withdrawal", new { accountId = Model.Account.Id })'">Create Withdrawal</button>
}
@if (Context.UserService().PossessesPermission(SFPermissionCodes.ChangeStatus))
{
<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("ChangeStatus", "ManageAccount", new { accountId = Model.Account.Id })'">Change Status</button>
}
</div>
<div id="tableContainer" class="jtable-div"></div>
</section>
@section scripts
{
<script src="~/scripts/dist/jquery-ui.min.js"></script>
<script src="~/scripts/dist/jtable.min.js"></script>
<script type="text/javascript">
$(function () {
$('#tableContainer').jtable({
title: 'Transaction History',
paging: true,
sorting: false,
columnSelectable: false,
AntiForgeryToken: '@Html.AntiForgeryTokenValue()',
actions: {
listAction: '@Url.Action("List", "AccountDetails", new { accountId = Model.Account.Id })',
},
fields: {
Id: {
key: true,
create: false,
edit: false,
list: false
},
Date: {
title: 'Date'
},
Amount: {
title: 'Amount'
},
TransactionType: {
title: 'Transaction Type',
options: { '0': 'Credit', '1': 'Debit' }
},
Remarks: {
title: 'Remarks'
}
}
}).jtable('load');
});
</script>
}
@section cssImport
{
<link href="@Url.Content(ViewBag.jTableStyle)" rel="stylesheet" type="text/css" />
<style>
.child-opener-image {
cursor: pointer;
}
.child-opener-image-column {
text-align: center;
}
.jtable-dialog-form {
min-width: 220px;
}
.jtable-dialog-form input[type="text"] {
min-width: 200px;
}
div.jtable-main-container > div.jtable-title {
background-color: #008cba;
}
.jtable-column-header {
background-color: #808080;
}
.jtable-toolbar > .jtable-toolbar-item {
background-color: #808080;
}
.jtable-command-column-header {
background-color: #808080;
}
div.jtable-main-container > div.jtable-bottom-panel {
background-color: #808080;
}
.jtable-page-list > jtable-page-number-disabled {
background-color: #808080;
}
div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-space, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-first, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-last, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-previous, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-next, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-active {
background-color: #808080;
}
div.jtable-main-container > div.jtable-title div.jtable-toolbar span.jtable-toolbar-item {
background-color: #808080;
}
div.jtable-main-container > div.jtable-title div.jtable-toolbar span.jtable-toolbar-item:hover {
background-color: #008cba;
}
</style>
}
- In above view, we’re rendering each transaction action button as per permission required for that action.
Manage Nominees
As part of this feature, we’ll build interfaces to let customers add or remove nominees for their accounts. A nominee has read-only access to the account it’s nominated for.
For this feature, we need the following:
- AccountNominee and AddNominee models.
AddNomineeAsync
,DeleteNomineeAsync
andGetNomineesAsync
in AccountManager.- NomineeController with actions to add, delete and list nominees.
- NomineeId related ReferencesLoader. We also need AccountId ReferencesLoader (already gone through that again in the previous section).
- Nominee listing grid and add nominee form views.
Models
AccountNominee
public class AccountNominee
{
public Guid? Id { get; set; }
public string Name { get; set; }
public string Username { get; set; }
public Guid? AccountId { get; set; }
}
AddNomineeModel
public class AddNomineeModel
{
[MaxLength(100)]
[Required]
[Display(Name = "Email")]
[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
[DoNotAuthorize]
public string Username { get; set; }
[Required]
public Guid? AccountId { get; set; }
}
- The
Username
inAddNomineeModel
could be referring to an existing user or a new user - see AccountManager section below for more details. Using DoNotAuthorizeAttribute to indicate to ADA to not consider this otherwise EntityId value for authorization.
AccountManager
AddNomineeAsync
public async Task<AccountNominee> AddNomineeAsync(Guid accountId, string nomineeEmail, string registerUrl,
string loginUrl)
{
var dbNomineeUser = await this.dbContext.Users.Include(x => x.PermitGroups)
.SingleOrDefaultAsync(x => x.Username == nomineeEmail).ConfigureAwait(false);
if (dbNomineeUser != null)
{
if (dbNomineeUser.UserType != UserType.Customer)
{
throw new OpException(OpResult.InvalidInput, string.Format(SFMessages.NotACustomerUser, nomineeEmail));
}
var dbAccountNominee = new DbAccountNominee
{
Id = Guid.NewGuid(),
AccountId = accountId,
NomineeUserId = dbNomineeUser.Id
};
this.dbContext.AccountNominees.Add(dbAccountNominee);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
await this.permitRepository
.AddPermitAsync(dbNomineeUser.Id, SFPermissionCodes.AccountNominee, accountId)
.ConfigureAwait(false);
await SendNomineeNotificationMailAsync(nomineeEmail, loginUrl).ConfigureAwait(false);
return new AccountNominee
{
Id = dbAccountNominee.Id,
Name = dbNomineeUser.Name,
Username = dbNomineeUser.Username,
AccountId = accountId
};
}
else
{
var dbNomineeInvitation = new DbUserInvitation
{
Id = Guid.NewGuid(),
Date = DateTime.UtcNow,
AccountId = accountId,
EmailAddress = nomineeEmail
};
this.dbContext.UserInvitations.Add(dbNomineeInvitation);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
await SendNomineeInviteMailAsync(nomineeEmail, registerUrl).ConfigureAwait(false);
return null;
}
}
DeleteNomineeAsync
public async Task DeleteNomineeAsync(Guid id)
{
var dbAccountNominee = await this.dbContext.AccountNominees
.Where(m => m.Id == id)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
if (dbAccountNominee != null)
{
await this.permitRepository
.RemovePermitAsync(dbAccountNominee.NomineeUserId, SFPermissionCodes.AccountNominee, dbAccountNominee.AccountId)
.ConfigureAwait(false);
this.dbContext.AccountNominees.Remove(dbAccountNominee);
await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
else
{
throw new OpException(OpResult.DoNotExist);
}
}
GetNomineesAsync
public async Task<List<AccountNominee>> GetNomineesAsync(Guid accountId)
{
return await this.dbContext.AccountNominees.Where(x => x.AccountId == accountId)
.OrderBy(x => x.NomineeUser.Name)
.Select(x => new AccountNominee
{
Id = x.Id,
Name = x.NomineeUser.Name,
Username = x.NomineeUser.Username,
AccountId = x.AccountId
})
.ToListAsync().ConfigureAwait(false);
}
- In
AddNomineeAsync
, the flow is working as follows:- If the nominee user already exists, create an
AccountNominee
record and grant an AccountNominee permit on theAccountId
to that user, and then notify the user about the new nomination she’s received. - If it doesn’t, create an invitation and send an invite email.
- If the nominee user already exists, create an
- In
DeleteNomineeAsync
, we’re deleting not just theAccountNominee
record, but also the associated AccountNominee permit we had granted to the user on the account.
NomineeController
List
[AuthAction("Index")]
[HttpPost]
public async Task<ActionResult> List(Guid accountId)
{
return await SecureJsonAction(async () =>
{
var result = await this.accountManager.GetNomineesAsync(accountId);
return Json(ApiResponse.List(result, result.Count));
});
}
Add
public ActionResult Add(Guid accountId)
{
var model = new AddNomineeModel
{
AccountId = accountId
};
return View(model);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Add(AddNomineeModel model)
{
if (ModelState.IsValid)
{
try
{
var nominee = await this.accountManager.AddNomineeAsync(model.AccountId.GetValueOrDefault(), model.Username, RegisterUrl(), LoginUrl());
return RedirectWithMessage("Index", "Account", null,
nominee == null ? SFMessages.NomineeInvited : SFMessages.NomineeCreated, OpResult.Success);
}
catch (OpException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
return View(model);
}
Delete
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Delete([Authorize("NomineeId")] Guid id)
{
return await SecureJsonAction(async () =>
{
await this.accountManager.DeleteNomineeAsync(id);
return Json(ApiResponse.Success());
});
}
- There’s no paging in
List
nominees action which is intentional. We don’t expect an account having that many nominees for paging to make sense (though it can if customer so wishes!)
NomineeId ReferencesLoader
public async Task<List<IdReference>> NomineeId(Guid id)
{
return await dbContext.AccountNominees
.Where(x => x.Id == id)
.Select(x => new List<Guid> { x.AccountId, x.Account.BranchId, x.Account.Branch.BankId })
.ToIdReferenceListAsync();
}
Authorization
- This time we aren’t using PossessesPermissionCodeAttribute on the List action because it has
AccountId
– a valid EntityId – as an input param which must go through data authorization. - Instead, we’re having AuthAction on List action to specify that for this action,
Index
is the action code to build the PermissionCode. - With NomineeId ReferencesLoader, ADA will get
BankId
,BranchId
andAccountId
as related references. Only one of them is required to exist in user’s permit set for the permission to authorize the given action. - The
Delete
action specifies AuthorizeAttribute forid
param - this is to specify the name -NomineeId
- by which the NomineeId ReferencesLoader is to be located to authorize the param.
Nominee Management UI
Try Out the Live Demo
Visit https://superfinance.ASPSecurityKit.net to play with a live demo based on this sample.